capstone 3 files
@ -0,0 +1,5 @@
|
||||
PORT = 4006
|
||||
MONGO_URI = 'mongodb+srv://FullStackCapstone3ByBr3y:FullStackCapstone3ByBr3y@ecomshop.rvywur0.mongodb.net/capstone3?retryWrites=true&w=majority&appName=AtlasApp'
|
||||
JWT_SECRET = Capstone3FSByAubreyLizardo
|
||||
NODE_ENV = development
|
||||
PAYPAL_CLIENT_ID =AUf7NXbMHXLJQt_NDuakwDQSJlc8z_89G1soQOVhsge3W5qunLZhmBFXK8tKc89DfyJpSqPuZA3hd1Wn
|
@ -0,0 +1 @@
|
||||
node_modules
|
@ -0,0 +1,17 @@
|
||||
# Zuitt Bootstrap Capstone 3 E-Commerce Web App
|
||||
|
||||
<!-- - setup
|
||||
- install tailwind css and npm init -y
|
||||
- npm i nodemon multer mongoose jsonwebtoken express-formidable express-async-handler express dotenv cors cookie-parser concurrently bcryptjs
|
||||
- inside frontend folder
|
||||
- npm i slick-carousel react-slick react-toastify react-router react-router-dom react-redux react-icons apexcharts react-apexcharts moment flowbite axios @reduxjs/toolkit @paypal/react-paypal-js -->
|
||||
|
||||
|
||||
- setup from different computer
|
||||
- npm install > cd frontend > npm install
|
||||
- to run frontend: npm run frontend
|
||||
- to run backend: npm run backend
|
||||
- to run both: npm run dev
|
||||
|
||||
|
||||
..
|
@ -0,0 +1,13 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGO_URI)
|
||||
console.log(`Sucessfully connected to mongoDB`)
|
||||
} catch(error){
|
||||
console.log(`ERROR: ${error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
export default connectDB
|
@ -0,0 +1,91 @@
|
||||
import Category from "../models/categoryModel.js";
|
||||
import asyncHandler from "../middleware/asyncHandler.js";
|
||||
|
||||
const createCategory = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.json({ error: "Name is totally required" });
|
||||
}
|
||||
|
||||
const existingCategory = await Category.findOne({ name });
|
||||
|
||||
if (existingCategory) {
|
||||
return res.json({ error: "Already exists" });
|
||||
}
|
||||
|
||||
const category = await new Category({ name }).save()
|
||||
res.json(category)
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return res.status(400).json(error);
|
||||
}
|
||||
});
|
||||
|
||||
const updateCategory = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
const { categoryId } = req.params;
|
||||
|
||||
const category = await Category.findOne({ _id: categoryId });
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: "Category not found" });
|
||||
}
|
||||
|
||||
category.name = name;
|
||||
|
||||
const updatedCategory = await category.save();
|
||||
res.json(updatedCategory);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
})
|
||||
|
||||
const removeCategory = asyncHandler(async(req, res) => {
|
||||
try{
|
||||
|
||||
const removed = await Category.findByIdAndDelete(req.params.categoryId)
|
||||
res.json(removed)
|
||||
|
||||
|
||||
} catch(error){
|
||||
console.log(error)
|
||||
res.status(500).json({error: "Internal server error"})
|
||||
}
|
||||
})
|
||||
|
||||
const listCategory = asyncHandler(async(req, res) => {
|
||||
try{
|
||||
|
||||
const allCategory = await Category.find({})
|
||||
res.json(allCategory)
|
||||
|
||||
} catch(error){
|
||||
console.log(error)
|
||||
res.status(400).json(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
const readCategory = asyncHandler(async(req, res) => {
|
||||
try{
|
||||
|
||||
const category = await Category.findOne({_id: req.params.id})
|
||||
res.json(category)
|
||||
|
||||
}catch(error){
|
||||
console.log(error)
|
||||
return res.status(400).json(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
export {
|
||||
createCategory,
|
||||
updateCategory,
|
||||
removeCategory,
|
||||
listCategory,
|
||||
readCategory
|
||||
};
|
@ -0,0 +1,214 @@
|
||||
import Order from "../models/orderModel.js";
|
||||
import Product from "../models/productModel.js";
|
||||
|
||||
// Utility Function
|
||||
function calcPrices(orderItems) {
|
||||
const itemsPrice = orderItems.reduce(
|
||||
(acc, item) => acc + item.price * item.qty,
|
||||
0
|
||||
);
|
||||
|
||||
const shippingPrice = itemsPrice > 100 ? 0 : 10;
|
||||
const taxRate = 0.15;
|
||||
const taxPrice = (itemsPrice * taxRate).toFixed(2);
|
||||
|
||||
const totalPrice = (
|
||||
itemsPrice +
|
||||
shippingPrice +
|
||||
parseFloat(taxPrice)
|
||||
).toFixed(2);
|
||||
|
||||
return {
|
||||
itemsPrice: itemsPrice.toFixed(2),
|
||||
shippingPrice: shippingPrice.toFixed(2),
|
||||
taxPrice,
|
||||
totalPrice,
|
||||
};
|
||||
}
|
||||
|
||||
const createOrder = async (req, res) => {
|
||||
try {
|
||||
const { orderItems, shippingAddress, paymentMethod } = req.body;
|
||||
|
||||
if (orderItems && orderItems.length === 0) {
|
||||
res.status(400);
|
||||
throw new Error("No order items");
|
||||
}
|
||||
|
||||
const itemsFromDB = await Product.find({
|
||||
_id: { $in: orderItems.map((x) => x._id) },
|
||||
});
|
||||
|
||||
const dbOrderItems = orderItems.map((itemFromClient) => {
|
||||
const matchingItemFromDB = itemsFromDB.find(
|
||||
(itemFromDB) => itemFromDB._id.toString() === itemFromClient._id
|
||||
);
|
||||
|
||||
if (!matchingItemFromDB) {
|
||||
res.status(404);
|
||||
throw new Error(`Product not found: ${itemFromClient._id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...itemFromClient,
|
||||
product: itemFromClient._id,
|
||||
price: matchingItemFromDB.price,
|
||||
_id: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const { itemsPrice, taxPrice, shippingPrice, totalPrice } =
|
||||
calcPrices(dbOrderItems);
|
||||
|
||||
const order = new Order({
|
||||
orderItems: dbOrderItems,
|
||||
user: req.user._id,
|
||||
shippingAddress,
|
||||
paymentMethod,
|
||||
itemsPrice,
|
||||
taxPrice,
|
||||
shippingPrice,
|
||||
totalPrice,
|
||||
});
|
||||
|
||||
const createdOrder = await order.save();
|
||||
res.status(201).json(createdOrder);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getAllOrders = async (req, res) => {
|
||||
try {
|
||||
const orders = await Order.find({}).populate("user", "id username");
|
||||
res.json(orders);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getUserOrders = async (req, res) => {
|
||||
try {
|
||||
const orders = await Order.find({ user: req.user._id });
|
||||
res.json(orders);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const countTotalOrders = async (req, res) => {
|
||||
try {
|
||||
const totalOrders = await Order.countDocuments();
|
||||
res.json({ totalOrders });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotalSales = async (req, res) => {
|
||||
try {
|
||||
const orders = await Order.find();
|
||||
const totalSales = orders.reduce((sum, order) => sum + order.totalPrice, 0);
|
||||
res.json({ totalSales });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const calcualteTotalSalesByDate = async (req, res) => {
|
||||
try {
|
||||
const salesByDate = await Order.aggregate([
|
||||
{
|
||||
$match: {
|
||||
isPaid: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: "%Y-%m-%d", date: "$paidAt" },
|
||||
},
|
||||
totalSales: { $sum: "$totalPrice" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
res.json(salesByDate);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const findOrderById = async (req, res) => {
|
||||
try {
|
||||
const order = await Order.findById(req.params.id).populate(
|
||||
"user",
|
||||
"username email"
|
||||
);
|
||||
|
||||
if (order) {
|
||||
res.json(order);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const markOrderAsPaid = async (req, res) => {
|
||||
try {
|
||||
const order = await Order.findById(req.params.id);
|
||||
|
||||
if (order) {
|
||||
order.isPaid = true;
|
||||
order.paidAt = Date.now();
|
||||
order.paymentResult = {
|
||||
id: req.body.id,
|
||||
status: req.body.status,
|
||||
update_time: req.body.update_time,
|
||||
email_address: req.body.payer.email_address,
|
||||
};
|
||||
|
||||
const updateOrder = await order.save();
|
||||
res.status(200).json(updateOrder);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const markOrderAsDelivered = async (req, res) => {
|
||||
try {
|
||||
const order = await Order.findById(req.params.id);
|
||||
|
||||
if (order) {
|
||||
order.isDelivered = true;
|
||||
order.deliveredAt = Date.now();
|
||||
|
||||
const updatedOrder = await order.save();
|
||||
res.json(updatedOrder);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
createOrder,
|
||||
getAllOrders,
|
||||
getUserOrders,
|
||||
countTotalOrders,
|
||||
calculateTotalSales,
|
||||
calcualteTotalSalesByDate,
|
||||
findOrderById,
|
||||
markOrderAsPaid,
|
||||
markOrderAsDelivered,
|
||||
};
|
@ -0,0 +1,220 @@
|
||||
import express from "express";
|
||||
import asyncHandler from "../middleware/asyncHandler.js";
|
||||
import Product from "../models/productModel.js";
|
||||
|
||||
const addProduct = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { name, description, price, category, quantity, brand } = req.fields;
|
||||
|
||||
// Validation
|
||||
switch (true) {
|
||||
case !name:
|
||||
return res.json({ error: "Name is required" });
|
||||
case !brand:
|
||||
return res.json({ error: "Brand is required" });
|
||||
case !description:
|
||||
return res.json({ error: "Description is required" });
|
||||
case !price:
|
||||
return res.json({ error: "Price is required" });
|
||||
case !category:
|
||||
return res.json({ error: "Category is required" });
|
||||
case !quantity:
|
||||
return res.json({ error: "Quantity is required" });
|
||||
}
|
||||
|
||||
const product = new Product({ ...req.fields });
|
||||
await product.save();
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const updateProductDetails = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { name, description, price, category, quantity, brand } = req.fields;
|
||||
|
||||
// Validation
|
||||
switch (true) {
|
||||
case !name:
|
||||
return res.json({ error: "Name is required" });
|
||||
case !brand:
|
||||
return res.json({ error: "Brand is required" });
|
||||
case !description:
|
||||
return res.json({ error: "Description is required" });
|
||||
case !price:
|
||||
return res.json({ error: "Price is required" });
|
||||
case !category:
|
||||
return res.json({ error: "Category is required" });
|
||||
case !quantity:
|
||||
return res.json({ error: "Quantity is required" });
|
||||
}
|
||||
|
||||
const product = await Product.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ ...req.fields },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
await product.save();
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const removeProduct = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const product = await Product.findByIdAndDelete(req.params.id);
|
||||
|
||||
res.json(product);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).json({ error: "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
const getAllProducts = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const pageSize = 6;
|
||||
const keyword = req.query.keyword
|
||||
? { name: { $regex: req.query.keyword, $options: "i" } }
|
||||
: {};
|
||||
|
||||
const count = await Product.countDocuments({ ...keyword });
|
||||
const products = await Product.find({ ...keyword }).limit(pageSize);
|
||||
|
||||
res.json({
|
||||
products,
|
||||
page: 1,
|
||||
pages: Math.ceil(count / pageSize),
|
||||
hasMore: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).json({ error: "Server error" });
|
||||
}
|
||||
});
|
||||
|
||||
const getProductById = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const product = await Product.findById(req.params.id);
|
||||
|
||||
if (product) {
|
||||
return res.json(product);
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(404).json({ error: "Product not found" });
|
||||
}
|
||||
});
|
||||
|
||||
const fetchAllProducts = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const products = await Product.find({})
|
||||
.populate("category")
|
||||
.limit(12)
|
||||
.sort({ createAt: -1 });
|
||||
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(500).json({ error: "Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
const addProductReview = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { rating, comment } = req.body;
|
||||
const product = await Product.findById(req.params.id);
|
||||
|
||||
if (product) {
|
||||
const alreadyReviewed = product.reviews.find(
|
||||
(review) => review.user.toString() === req.user._id.toString()
|
||||
);
|
||||
|
||||
if (alreadyReviewed) {
|
||||
res.status(400);
|
||||
throw new Error("Product already reviewed");
|
||||
}
|
||||
|
||||
const review = {
|
||||
name: req.user.username,
|
||||
rating: Number(rating),
|
||||
comment,
|
||||
user: req.user._id,
|
||||
};
|
||||
|
||||
product.reviews.push(review);
|
||||
product.numReviews = product.reviews.length;
|
||||
|
||||
product.rating =
|
||||
product.reviews.reduce((acc, item) => item.rating + acc, 0) /
|
||||
product.reviews.length;
|
||||
|
||||
await product.save();
|
||||
res.status(201).json({ message: "Review added" });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchTopProducts = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const products = await Product.find({}).sort({ rating: -1 }).limit(4);
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchNewProducts = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const products = await Product.find().sort({ _id: -1 }).limit(5);
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(400).json(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const filterProducts = asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { checked, radio } = req.body;
|
||||
|
||||
let args = {};
|
||||
if (checked.length > 0) args.category = checked;
|
||||
if (radio.length) args.price = { $gte: radio[0], $lte: radio[1] };
|
||||
|
||||
const products = await Product.find(args);
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Server Error" });
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
updateProductDetails,
|
||||
removeProduct,
|
||||
getAllProducts,
|
||||
getProductById,
|
||||
fetchAllProducts,
|
||||
addProductReview,
|
||||
fetchTopProducts,
|
||||
fetchNewProducts,
|
||||
filterProducts,
|
||||
};
|
@ -0,0 +1,191 @@
|
||||
import User from '../models/userModel.js'
|
||||
import asyncHandler from '../middleware/asyncHandler.js'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import createToken from '../utils/createToken.js'
|
||||
|
||||
const createUser = asyncHandler(async (req, res) => {
|
||||
const { username, email, password, isAdmin } = req.body
|
||||
|
||||
if (!username || !email || !password) {
|
||||
throw new Error('Please fill all the inputs')
|
||||
}
|
||||
|
||||
const userExists = await User.findOne({ email })
|
||||
if (userExists) {
|
||||
res.status(400)
|
||||
throw new Error("User already exist")
|
||||
}
|
||||
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
const hashedPassword = await bcrypt.hash(password, salt)
|
||||
|
||||
const newUser = new User({
|
||||
username,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
isAdmin: isAdmin
|
||||
})
|
||||
|
||||
try {
|
||||
await newUser.save()
|
||||
// generateToken
|
||||
createToken(res, newUser._id)
|
||||
|
||||
res.status(201).json({
|
||||
_id: newUser._id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
isAdmin: newUser.isAdmin
|
||||
})
|
||||
} catch (error) {
|
||||
res.status(400)
|
||||
throw new Error("Invalid user data")
|
||||
}
|
||||
})
|
||||
|
||||
const loginUser = asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body
|
||||
const existingUser = await User.findOne({ email })
|
||||
|
||||
if (existingUser) {
|
||||
const isPasswordValid = await bcrypt.compare(password, existingUser.password)
|
||||
|
||||
if (isPasswordValid) {
|
||||
createToken(res, existingUser._id)
|
||||
|
||||
res.status(201).json({
|
||||
_id: existingUser._id,
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
isAdmin: existingUser.isAdmin
|
||||
})
|
||||
} else {
|
||||
res.status(400)
|
||||
throw new Error("Wrong Password")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const logoutCurrentUser = asyncHandler(async (req, res) => {
|
||||
res.cookie('jwt', '', {
|
||||
httpOnly: true,
|
||||
expires: new Date(0)
|
||||
})
|
||||
|
||||
res.status(200).json({ message: "Logged out sucessfully -cookie removed-" })
|
||||
})
|
||||
|
||||
const getAllUsers = asyncHandler(async (req, res) => {
|
||||
const users = await User.find({})
|
||||
res.json(users)
|
||||
})
|
||||
|
||||
const getCurrentUserProfile = asyncHandler(async (req, res) => {
|
||||
const user = await User.findById(req.user._id)
|
||||
|
||||
if (user) {
|
||||
res.json({
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
})
|
||||
} else {
|
||||
res.status(404)
|
||||
throw new Error("User not found")
|
||||
}
|
||||
})
|
||||
|
||||
const updateCurrentUserProfile = asyncHandler(async (req, res) => {
|
||||
const user = await User.findById(req.user._id)
|
||||
|
||||
if (user) {
|
||||
user.username = req.body.username || user.username
|
||||
user.email = req.body.email || user.email
|
||||
|
||||
if (req.body.password) {
|
||||
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
const hashedPassword = await bcrypt.hash(req.body.password, salt)
|
||||
user.password = hashedPassword
|
||||
|
||||
}
|
||||
|
||||
const updatedUser = await user.save()
|
||||
|
||||
res.json({
|
||||
_id: updatedUser._id,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
isAdmin: updatedUser.isAdmin
|
||||
})
|
||||
} else {
|
||||
res.status(404)
|
||||
throw new Error("User not found")
|
||||
}
|
||||
})
|
||||
|
||||
const deletUserById = asyncHandler(async(req, res) => {
|
||||
|
||||
const user = await User.findById(req.params.id)
|
||||
|
||||
if(user){
|
||||
if(user.isAdmin){
|
||||
res.status(400)
|
||||
throw new Error('Cannot delete admin user')
|
||||
}
|
||||
|
||||
await User.deleteOne({_id: user._id})
|
||||
res.json({message: "User removed"})
|
||||
} else {
|
||||
res.status(404)
|
||||
throw new Error("User not found")
|
||||
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
const getUserById = asyncHandler(async(req, res) => {
|
||||
const user = await User.findById(req.params.id).select('-password')
|
||||
|
||||
if(user){
|
||||
res.json(user)
|
||||
} else {
|
||||
res.status(404)
|
||||
throw new Error("User not found")
|
||||
}
|
||||
})
|
||||
|
||||
const updateUserById = asyncHandler(async(req, res) => {{
|
||||
const user = await User.findById(req.params.id)
|
||||
|
||||
if(user){
|
||||
user.username = req.body.username || user.username
|
||||
user.email = req.body.email || user.email
|
||||
user.isAdmin = Boolean(req.body.isAdmin)
|
||||
|
||||
const updatedUser = await user.save()
|
||||
|
||||
res.json({
|
||||
_id: updatedUser._id,
|
||||
username: updatedUser.username,
|
||||
email:updatedUser.email,
|
||||
isAdmin: updatedUser.isAdmin
|
||||
})
|
||||
} else {
|
||||
res.status(404)
|
||||
throw new Error("User not found")
|
||||
}
|
||||
}})
|
||||
|
||||
|
||||
export {
|
||||
createUser,
|
||||
loginUser,
|
||||
logoutCurrentUser,
|
||||
getAllUsers,
|
||||
getCurrentUserProfile,
|
||||
updateCurrentUserProfile,
|
||||
deletUserById,
|
||||
getUserById,
|
||||
updateUserById
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
// packages
|
||||
import path from 'path'
|
||||
import express from 'express'
|
||||
import dotenv from 'dotenv'
|
||||
import cookieParser from 'cookie-parser'
|
||||
|
||||
// utils
|
||||
import connectDB from './config/db.js'
|
||||
import userRoute from './routes/userRoute.js'
|
||||
import categoryRoute from './routes/categoryRoute.js'
|
||||
import productRoute from './routes/productRoute.js'
|
||||
import uploadRoute from './routes/uploadRoute.js'
|
||||
import orderRoutes from "./routes/orderRoute.js";
|
||||
|
||||
dotenv.config()
|
||||
const port = process.env.PORT || 4000
|
||||
|
||||
connectDB()
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(cookieParser())
|
||||
|
||||
app.use('/b6/users', userRoute)
|
||||
app.use('/b6/category', categoryRoute)
|
||||
app.use('/b6/products', productRoute)
|
||||
app.use('/b6/upload', uploadRoute)
|
||||
app.use("/b6/orders", orderRoutes);
|
||||
|
||||
app.get('/b6/config/paypal', (req, res) => {
|
||||
res.send({clientId: process.env.PAYPAL_CLIENT_ID})
|
||||
})
|
||||
|
||||
const __dirname = path.resolve()
|
||||
app.use('/uploads', express.static(path.join(__dirname, '/uploads')))
|
||||
|
||||
app.listen(port, () => console.log(`Server running on port: ${port}`))
|
@ -0,0 +1,7 @@
|
||||
const asyncHandler = (fn) => (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(error => {
|
||||
res.status(500).json({message: error.message})
|
||||
})
|
||||
}
|
||||
|
||||
export default asyncHandler
|
@ -0,0 +1,38 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../models/userModel.js'
|
||||
import asyncHandler from './asyncHandler.js'
|
||||
|
||||
const authenticate = asyncHandler(async(req, res, next) => {
|
||||
let token
|
||||
|
||||
// Read JWT from the 'jwt' cookie
|
||||
token = req.cookies.jwt
|
||||
|
||||
if(token) {
|
||||
try{
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
req.user = await User.findById(decoded.userId).select('-password')
|
||||
next()
|
||||
|
||||
} catch(error){
|
||||
res.status(401)
|
||||
throw new Error("Not authorized, token failed.")
|
||||
}
|
||||
} else {
|
||||
res.status(401)
|
||||
throw new Error("Not authorized, no token.")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// Check if user is admin
|
||||
const authorizeAdmin = (req, res, next) => {
|
||||
if(req.user.isAdmin){
|
||||
next()
|
||||
} else {
|
||||
res.status(401).send("Not authorized as an admin.")
|
||||
}
|
||||
}
|
||||
|
||||
export { authenticate, authorizeAdmin }
|
@ -0,0 +1,12 @@
|
||||
import { isValidObjectId } from "mongoose";
|
||||
|
||||
function checkId( req, res, next ) {
|
||||
if(!isValidObjectId(req.params.id)){
|
||||
res.status(404)
|
||||
throw new Error(`Invalid Object of: ${req.params.id}`)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
export default checkId
|
@ -0,0 +1,14 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const categorySchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
trim: true,
|
||||
require: true,
|
||||
maxLength: 32,
|
||||
unique: true,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
export default mongoose.model('Category', categorySchema)
|
@ -0,0 +1,89 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const orderSchema = mongoose.Schema(
|
||||
{
|
||||
user: { type: mongoose.Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
orderItems: [
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
qty: { type: Number, required: true },
|
||||
image: { type: String, required: true },
|
||||
price: { type: Number, required: true },
|
||||
product: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "Product",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
shippingAddress: {
|
||||
address: { type: String, required: true },
|
||||
city: { type: String, required: true },
|
||||
postalCode: { type: String, required: true },
|
||||
country: { type: String, required: true },
|
||||
},
|
||||
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
paymentResult: {
|
||||
id: { type: String },
|
||||
status: { type: String },
|
||||
update_time: { type: String },
|
||||
email_address: { type: String },
|
||||
},
|
||||
|
||||
itemsPrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0.0,
|
||||
},
|
||||
|
||||
taxPrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0.0,
|
||||
},
|
||||
|
||||
shippingPrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0.0,
|
||||
},
|
||||
|
||||
totalPrice: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0.0,
|
||||
},
|
||||
|
||||
isPaid: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
|
||||
paidAt: {
|
||||
type: Date,
|
||||
},
|
||||
|
||||
isDelivered: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
|
||||
deliveredAt: {
|
||||
type: Date,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
const Order = mongoose.model("Order", orderSchema);
|
||||
export default Order;
|
@ -0,0 +1,77 @@
|
||||
import mongoose from "mongoose";
|
||||
const { ObjectId } = mongoose.Schema;
|
||||
|
||||
const reviewSchema = mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
comment: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
user: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "User",
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
const productSchema = mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
brand: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
category: {
|
||||
type: ObjectId,
|
||||
ref: "Category",
|
||||
required: true,
|
||||
},
|
||||
description: { type: String, required: true },
|
||||
reviews: [reviewSchema],
|
||||
rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
numReviews: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
countInStock: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
const Product = mongoose.model("Product", productSchema);
|
||||
export default Product;
|
@ -0,0 +1,26 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
const userSchema = mongoose.Schema({
|
||||
username: {
|
||||
type:String,
|
||||
required: true,
|
||||
},
|
||||
email: {
|
||||
type:String,
|
||||
required:true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type:String,
|
||||
required: true,
|
||||
},
|
||||
isAdmin: {
|
||||
type:Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
}
|
||||
}, { timestamps: true })
|
||||
|
||||
const User = mongoose.model('USer', userSchema)
|
||||
|
||||
export default User
|
@ -0,0 +1,25 @@
|
||||
import express from "express";
|
||||
import {
|
||||
createCategory,
|
||||
updateCategory,
|
||||
removeCategory,
|
||||
listCategory,
|
||||
readCategory
|
||||
} from "../controllers/categoryController.js";
|
||||
import { authenticate, authorizeAdmin } from "../middleware/authMiddleWare.js";
|
||||
const router = express.Router();
|
||||
|
||||
router.route("/").post(authenticate, authorizeAdmin, createCategory);
|
||||
router
|
||||
.route("/:categoryId")
|
||||
.put(authenticate, authorizeAdmin, updateCategory)
|
||||
.delete(authenticate, authorizeAdmin, removeCategory)
|
||||
|
||||
router
|
||||
.route("/categories")
|
||||
.get(listCategory)
|
||||
|
||||
router
|
||||
.route("/:id")
|
||||
.get(readCategory)
|
||||
export default router;
|
@ -0,0 +1,33 @@
|
||||
import express from "express";
|
||||
const router = express.Router();
|
||||
|
||||
import {
|
||||
createOrder,
|
||||
getAllOrders,
|
||||
getUserOrders,
|
||||
countTotalOrders,
|
||||
calculateTotalSales,
|
||||
calcualteTotalSalesByDate,
|
||||
findOrderById,
|
||||
markOrderAsPaid,
|
||||
markOrderAsDelivered,
|
||||
} from "../controllers/orderController.js";
|
||||
|
||||
import { authenticate, authorizeAdmin } from "../middleware/authMiddleWare.js";
|
||||
|
||||
router
|
||||
.route("/")
|
||||
.post(authenticate, createOrder)
|
||||
.get(authenticate, authorizeAdmin, getAllOrders);
|
||||
|
||||
router.route("/mine").get(authenticate, getUserOrders);
|
||||
router.route("/total-orders").get(countTotalOrders);
|
||||
router.route("/total-sales").get(calculateTotalSales);
|
||||
router.route("/total-sales-by-date").get(calcualteTotalSalesByDate);
|
||||
router.route("/:id").get(authenticate, findOrderById);
|
||||
router.route("/:id/pay").put(authenticate, markOrderAsPaid);
|
||||
router
|
||||
.route("/:id/deliver")
|
||||
.put(authenticate, authorizeAdmin, markOrderAsDelivered);
|
||||
|
||||
export default router;
|
@ -0,0 +1,39 @@
|
||||
import express from "express";
|
||||
import formidable from "express-formidable";
|
||||
const router = express.Router();
|
||||
|
||||
import {
|
||||
addProduct,
|
||||
updateProductDetails,
|
||||
removeProduct,
|
||||
getAllProducts,
|
||||
getProductById,
|
||||
fetchAllProducts,
|
||||
addProductReview,
|
||||
fetchTopProducts,
|
||||
fetchNewProducts,
|
||||
filterProducts,
|
||||
} from "../controllers/productController.js";
|
||||
import { authenticate, authorizeAdmin } from "../middleware/authMiddleWare.js";
|
||||
import checkId from "../middleware/checkId.js";
|
||||
|
||||
router.route("/fetchAllProducts").get(fetchAllProducts);
|
||||
|
||||
router.route("/:id/reviews").post(authenticate, checkId, addProductReview);
|
||||
|
||||
router.route("/top").get(fetchTopProducts);
|
||||
router.route("/new").get(fetchNewProducts);
|
||||
|
||||
router
|
||||
.route("/")
|
||||
.post(authenticate, authorizeAdmin, formidable(), addProduct)
|
||||
.get(getAllProducts);
|
||||
router
|
||||
.route("/:id")
|
||||
.get(getProductById)
|
||||
.put(authenticate, authorizeAdmin, formidable(), updateProductDetails)
|
||||
.delete(authenticate, authorizeAdmin, removeProduct);
|
||||
|
||||
router.route("/filtered-products").post(filterProducts);
|
||||
|
||||
export default router;
|
@ -0,0 +1,50 @@
|
||||
import path from "path";
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, "uploads/");
|
||||
},
|
||||
|
||||
filename: (req, file, cb) => {
|
||||
const extname = path.extname(file.originalname);
|
||||
cb(null, `${file.fieldname}-${Date.now()}${extname}`);
|
||||
},
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const filetypes = /jpe?g|png|webp/;
|
||||
const mimetypes = /image\/jpe?g|image\/png|image\/webp/;
|
||||
|
||||
const extname = path.extname(file.originalname).toLowerCase();
|
||||
const mimetype = file.mimetype;
|
||||
|
||||
if (filetypes.test(extname) && mimetypes.test(mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Images only"), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({ storage, fileFilter });
|
||||
const uploadSingleImage = upload.single("image");
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
uploadSingleImage(req, res, (err) => {
|
||||
if (err) {
|
||||
res.status(400).send({ message: err.message });
|
||||
} else if (req.file) {
|
||||
res.status(200).send({
|
||||
message: "Image uploaded successfully",
|
||||
image: `/${req.file.path}`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).send({ message: "No image file provided" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
@ -0,0 +1,35 @@
|
||||
import express from "express";
|
||||
import {
|
||||
createUser,
|
||||
loginUser,
|
||||
logoutCurrentUser,
|
||||
getAllUsers,
|
||||
getCurrentUserProfile,
|
||||
updateCurrentUserProfile,
|
||||
deletUserById,
|
||||
getUserById,
|
||||
updateUserById,
|
||||
} from "../controllers/userController.js";
|
||||
|
||||
import { authenticate, authorizeAdmin } from "../middleware/authMiddleWare.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/register", createUser);
|
||||
|
||||
router.post("/login", loginUser);
|
||||
router.post("/logout", logoutCurrentUser);
|
||||
|
||||
router
|
||||
.route("/profile")
|
||||
.get(authenticate, getCurrentUserProfile)
|
||||
.put(authenticate, updateCurrentUserProfile);
|
||||
|
||||
router.get("/getAllUsers", authenticate, authorizeAdmin, getAllUsers);
|
||||
router
|
||||
.route("/:id")
|
||||
.delete(authenticate, authorizeAdmin, deletUserById)
|
||||
.get(authenticate, authorizeAdmin, getUserById)
|
||||
.put(authenticate, authorizeAdmin, updateUserById);
|
||||
|
||||
export default router;
|
@ -0,0 +1,18 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const generateToken = (res, userId) => {
|
||||
const token = jwt.sign({userId}, process.env.JWT_SECRET, { expiresIn: "30d"})
|
||||
|
||||
// Set JWT as HTTP-Only Cookie
|
||||
res.cookie('jwt', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV !== 'development',
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 60 * 60 * 100
|
||||
})
|
||||
|
||||
return token
|
||||
|
||||
}
|
||||
|
||||
export default generateToken
|
@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
@ -0,0 +1,8 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Capstone 3FS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "capstone3fs",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"apexcharts": "^3.44.0",
|
||||
"axios": "^1.6.2",
|
||||
"flowbite": "^2.1.1",
|
||||
"moment": "^2.29.4",
|
||||
"react": "^18.2.0",
|
||||
"react-apexcharts": "^1.4.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router": "^6.19.0",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"react-slick": "^0.29.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"slick-carousel": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Navigation from './pages/Auth/Navigation'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
|
||||
function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<Navigation />
|
||||
<main className='py-3'>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@ -0,0 +1,49 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useGetProductsQuery } from "./redux/api/productApiSlice";
|
||||
import Loader from "./components/Loader";
|
||||
import Header from "./components/Header";
|
||||
import Message from "./components/Message";
|
||||
import Product from "./pages/Products/Product";
|
||||
|
||||
|
||||
const Home = () => {
|
||||
const { keyword } = useParams();
|
||||
const { data, isLoading, isError } = useGetProductsQuery({ keyword });
|
||||
|
||||
return (
|
||||
<>
|
||||
{!keyword ? <Header /> : null}
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : isError ? (
|
||||
<Message variant="danger">
|
||||
{isError?.data.message || isError.error}
|
||||
</Message>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="ml-[20rem] mt-[10rem] text-[3rem]">
|
||||
Special Product
|
||||
</h1>
|
||||
|
||||
<Link
|
||||
to="/shop"
|
||||
className="bg-pink-600 text-white font-bold rounded-full py-2 px-10 mr-[18rem] mt-[10rem]"
|
||||
>
|
||||
Shop
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex justify-center flex-wrap mt-[2rem]">
|
||||
{data.products.map((product) => (
|
||||
<div key={product._id}>
|
||||
<Product product={product} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
@ -0,0 +1,28 @@
|
||||
export const addDecimals = (num) => {
|
||||
return (Math.round(num * 100) / 100).toFixed(2);
|
||||
};
|
||||
|
||||
export const updateCart = (state) => {
|
||||
// Calculate the items price
|
||||
state.itemsPrice = addDecimals(
|
||||
state.cartItems.reduce((acc, item) => acc + item.price * item.qty, 0)
|
||||
);
|
||||
|
||||
// Calculate the shipping price
|
||||
state.shippingPrice = addDecimals(state.itemsPrice > 100 ? 0 : 10);
|
||||
|
||||
// Calculate the tax price
|
||||
state.taxPrice = addDecimals(Number((0.15 * state.itemsPrice).toFixed(2)));
|
||||
|
||||
// Calculate the total price
|
||||
state.totalPrice = (
|
||||
Number(state.itemsPrice) +
|
||||
Number(state.shippingPrice) +
|
||||
Number(state.taxPrice)
|
||||
).toFixed(2);
|
||||
|
||||
// Save the cart to localStorage
|
||||
localStorage.setItem("cart", JSON.stringify(state));
|
||||
|
||||
return state;
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
// Add a product to a localStorage
|
||||
export const addFavoritesToLocalStorage = (product) => {
|
||||
const favorites = getFavoritesFromLocalStorage();
|
||||
if (!favorites.some((p) => p._id === product._id)) {
|
||||
favorites.push(product);
|
||||
localStorage.setItem("favorites", JSON.stringify(favorites));
|
||||
}
|
||||
};
|
||||
|
||||
// Remove
|
||||
export const removeFavoritesFromLocalStorage = (productId) => {
|
||||
const favorites = getFavoritesFromLocalStorage();
|
||||
const updateFavorites = favorites.filter(
|
||||
(product) => product._id !== productId
|
||||
);
|
||||
localStorage.setItem("favorites", JSON.stringify(updateFavorites));
|
||||
};
|
||||
|
||||
// Retrieve
|
||||
export const getFavoritesFromLocalStorage = () => {
|
||||
const favoritesJSON = localStorage.getItem("favorites");
|
||||
return favoritesJSON ? JSON.parse(favoritesJSON) : [];
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
const CategoryForm = ({
|
||||
value,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
buttonText = "Submit",
|
||||
handleDelete,
|
||||
}) => {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
className="py-3 px-4 border rounded-lg w-full"
|
||||
placeholder="Write category name"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button className="bg-pink-500 text-white py-2 px-4 rounded-lg hover:bg-pink-600 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-opacity-50">
|
||||
{buttonText}
|
||||
</button>
|
||||
|
||||
{handleDelete && (
|
||||
<button onClick={handleDelete} className="bg-red-500 text-white py-2 px-4 rounded-lg hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryForm;
|
@ -0,0 +1,35 @@
|
||||
import { useGetTopProductQuery } from "../redux/api/productApiSlice";
|
||||
import Loader from "./Loader";
|
||||
import SmallProduct from '../pages/Products/SmallProduct'
|
||||
import ProductCarousel from "../pages/Products/ProductCarousel";
|
||||
|
||||
const Header = () => {
|
||||
const { data, isLoading, error } = useGetTopProductQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <h1>Error</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-around">
|
||||
<div className="xl:block lg:hidden md:hidden sm:hidden">
|
||||
<div className="grid grid-cols-2">
|
||||
{data.map((product) => (
|
||||
<div key={product._id}>
|
||||
<SmallProduct product={product} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ProductCarousel />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
@ -0,0 +1,9 @@
|
||||
const Loader = () => {
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-pink-500 border-opacity-50">
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
@ -0,0 +1,15 @@
|
||||
const Message = ({ variant, children }) => {
|
||||
const getVariantClass = () => {
|
||||
switch (variant) {
|
||||
case "success":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "error":
|
||||
return "bg-red-100 text-red-800";
|
||||
default:
|
||||
return "bg-blue-100 text-blue-800";
|
||||
}
|
||||
};
|
||||
return <div className={`p-4 rounded ${getVariantClass()}`}>{children}</div>;
|
||||
};
|
||||
|
||||
export default Message;
|
@ -0,0 +1,22 @@
|
||||
const Modal = ({ isOpen, onClose, children }) => {
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50">
|
||||
<div className="fixed inset-0 bg-black opacity-50"></div>
|
||||
<div className="absolute top-[40%] right-[50%] bg-white p-4 rounded-lg z-10 text-right">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-black font-semibold hover:text-gray-700 focus:outline-none mr-2"
|
||||
>
|
||||
-X-
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
@ -0,0 +1,11 @@
|
||||
import {Navigate, Outlet} from 'react-router-dom'
|
||||
import {useSelector} from 'react-redux'
|
||||
|
||||
const PrivateRoute = () => {
|
||||
const { userInfo } = useSelector(state => state.auth)
|
||||
return (
|
||||
userInfo ? <Outlet /> : <Navigate to='/login' replace />
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivateRoute
|
@ -0,0 +1,39 @@
|
||||
const ProgressSteps = ({ step1, step2, step3 }) => {
|
||||
return (
|
||||
<div className="flex justify-center items-center space-x-4">
|
||||
<div className={`${step1 ? "text-green-500" : "text-gray-300"}`}>
|
||||
<span className="ml-2">Login</span>
|
||||
<div className="mt-2 text-lg text-center">✅</div>
|
||||
</div>
|
||||
|
||||
{step2 && (
|
||||
<>
|
||||
{step1 && <div className="h-0.5 w-[10rem] bg-green-500"></div>}
|
||||
<div className={`${step1 ? "text-green-500" : "text-gray-300"}`}>
|
||||
<span>Shipping</span>
|
||||
<div className="mt-2 text-lg text-center">✅</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{step1 && step2 && step3 ? (
|
||||
<div className="h-0.5 w-[10rem] bg-green-500"></div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<div className={`${step3 ? "text-green-500" : "text-gray-300"}`}>
|
||||
<span className={`${!step3 ? "ml-[10rem]" : ""}`}>Summary</span>
|
||||
{step1 && step2 && step3 ? (
|
||||
<div className="mt-2 text-lg text-center">✅</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressSteps;
|
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./index.css";
|
||||
import { Route, RouterProvider, createRoutesFromElements } from "react-router";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./redux/store.js";
|
||||
|
||||
// Private Route
|
||||
import PrivateRoute from "./components/PrivateRoute.jsx";
|
||||
|
||||
// Auth
|
||||
import Login from "./pages/Auth/Login.jsx";
|
||||
import Register from "./pages/Auth/Register.jsx";
|
||||
|
||||
import Profile from "./pages/User/Profile.jsx";
|
||||
|
||||
import AdminRoute from "./pages/Admin/AdminRoute.jsx";
|
||||
import UserList from "./pages/Admin/UserList.jsx";
|
||||
import CategoryList from "./pages/Admin/CategoryList.jsx";
|
||||
|
||||
import ProductList from "./pages/Admin/ProductList";
|
||||
import AllProducts from "./pages/Admin/AllProducts";
|
||||
import ProductUpdate from "./pages/Admin/ProductUpdate";
|
||||
import Home from "./Home.jsx";
|
||||
import Favorites from "./pages/Products/Favorites.jsx";
|
||||
import ProductDetails from "./pages/Products/ProductDetails.jsx";
|
||||
import Cart from "./pages/Cart.jsx";
|
||||
import Shop from "./pages/Shop.jsx";
|
||||
import Shipping from "./pages/Orders/Shipping.jsx";
|
||||
import PlaceOrder from "./pages/Orders/PlaceOrder.jsx";
|
||||
import Order from "./pages/Orders/Order.jsx";
|
||||
import UserOrder from "./pages/User/UserOrder.jsx"
|
||||
import { PayPalScriptProvider } from '@paypal/react-paypal-js'
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route path="/" element={<App />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route index={true} path="/" element={<Home />} />
|
||||
<Route path="favorite" element={<Favorites />} />
|
||||
<Route path="/product/:id" element={<ProductDetails />} />
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/user-orders" element={<UserOrder />} />
|
||||
|
||||
<Route path="" element={<PrivateRoute />}>
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/shipping" element={<Shipping />} />
|
||||
<Route path="/placeorder" element={<PlaceOrder />} />
|
||||
<Route path="/order/:id" element={<Order />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin */}
|
||||
<Route path="/admin" element={<AdminRoute />}>
|
||||
<Route path="userlist" element={<UserList />} />
|
||||
<Route path="categorylist" element={<CategoryList />} />
|
||||
<Route path="productlist" element={<ProductList />} />
|
||||
<Route path="allproductslist" element={<AllProducts />} />
|
||||
<Route path="productlist/:pageNumber" element={<ProductList />} />
|
||||
<Route path="product/update/:_id" element={<ProductUpdate />} />
|
||||
</Route>
|
||||
</Route>
|
||||
)
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<Provider store={store}>
|
||||
<PayPalScriptProvider>
|
||||
<RouterProvider router={router} />
|
||||
</PayPalScriptProvider>
|
||||
</Provider>
|
||||
);
|
@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { FaTimes } from "react-icons/fa";
|
||||
|
||||
const AdminMenu = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={`${
|
||||
isMenuOpen ? "top-2 right-2" : "top-5 right-7"
|
||||
} bg-[#151515] p-2 fixed rounded-lg`}
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<FaTimes color="white" />
|
||||
) : (
|
||||
<>
|
||||
<div className="w-6 h-0.5 bg-gray-200 my-1"></div>
|
||||
<div className="w-6 h-0.5 bg-gray-200 my-1"></div>
|
||||
<div className="w-6 h-0.5 bg-gray-200 my-1"></div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<section className="bg-[#151515] p-4 fixed right-2 top-8">
|
||||
<ul className="list-none mt-2">
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/dashboard"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
Admin Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/categorylist"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
Create Category
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/productlist"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
Create Product
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/allproductslist"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
All Products
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/userlist"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
Manage Users
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
className="py-2 px-3 block mb-5 hover:bg-[#2E2D2D] rounded-sm"
|
||||
to="/admin/orderlist"
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? "greenyellow" : "white",
|
||||
})}
|
||||
>
|
||||
Manage Orders
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMenu;
|
@ -0,0 +1,15 @@
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const AdminRoute = () => {
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
return userInfo && userInfo.isAdmin ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default AdminRoute;
|
@ -0,0 +1,92 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import moment from "moment";
|
||||
import { useAllProductsQuery } from "../../redux/api/productApiSlice";
|
||||
import AdminMenu from "./AdminMenu";
|
||||
|
||||
const AllProducts = () => {
|
||||
const { data: products, isLoading, isError } = useAllProductsQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading products</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container mx-[9rem]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="p-3">
|
||||
<div className="ml-[2rem] text-xl font-bold h-12">
|
||||
All Products ({products.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-around items-center">
|
||||
{products.map((product) => (
|
||||
<Link
|
||||
key={product._id}
|
||||
to={`/admin/product/update/${product._id}`}
|
||||
className="block mb-4 overflow-hidden"
|
||||
>
|
||||
<div className="flex">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-[10rem] object-cover"
|
||||
/>
|
||||
<div className="p-4 flex flex-col justify-around">
|
||||
<div className="flex justify-between">
|
||||
<h5 className="text-xl font-semibold mb-2">
|
||||
{product?.name}
|
||||
</h5>
|
||||
|
||||
<p className="text-gray-400 text-xs">
|
||||
{moment(product.createdAt).format("MMMM Do YYYY")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 xl:w-[30rem] lg:w-[30rem] md:w-[20rem] sm:w-[10rem] text-sm mb-4">
|
||||
{product?.description?.substring(0, 160)}...
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Link
|
||||
to={`/admin/product/update/${product._id}`}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-pink-700 rounded-lg hover:bg-pink-800 focus:ring-4 focus:outline-none focus:ring-pink-300 dark:bg-pink-600 dark:hover:bg-pink-700 dark:focus:ring-pink-800"
|
||||
>
|
||||
Update Product
|
||||
<svg
|
||||
className="w-3.5 h-3.5 ml-2"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 10"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M1 5h12m0 0L9 1m4 4L9 9"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<p>$ {product?.price}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/4 p-3 mt-2">
|
||||
<AdminMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllProducts;
|
@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useCreateCategoryMutation,
|
||||
useUpdateCategoryMutation,
|
||||
useDeleteCategoryMutation,
|
||||
useFetchCategoriesQuery,
|
||||
} from "../../redux/api/categoryApiSlice";
|
||||
import { toast } from "react-toastify";
|
||||
import CategoryForm from "../../components/CategoryForm";
|
||||
import Modal from "../../components/Modal";
|
||||
import AdminMenu from './AdminMenu'
|
||||
|
||||
const CategoryList = () => {
|
||||
const { data: categories } = useFetchCategoriesQuery();
|
||||
const [name, setName] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [updatingName, setUpdatingName] = useState("");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
const [createCategory] = useCreateCategoryMutation();
|
||||
const [updateCategory] = useUpdateCategoryMutation();
|
||||
const [deleteCategory] = useDeleteCategoryMutation();
|
||||
|
||||
const handleCreateCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name) {
|
||||
toast.error("Category is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCategory({ name }).unwrap();
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
setName("");
|
||||
toast.success(`${result.name} is created.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Creating category failed...");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCategory = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!updatingName) {
|
||||
toast.error("Category name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateCategory({
|
||||
categoryId: selectedCategory._id,
|
||||
updatedCategory: {
|
||||
name: updatingName,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(`${result.name} is updated`);
|
||||
selectedCategory(null);
|
||||
setUpdatingName("");
|
||||
setModalVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCategory = async () => {
|
||||
try {
|
||||
const result = await deleteCategory(selectedCategory._id).unwrap();
|
||||
if (result.error) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success(`${result.name} is deleted.`);
|
||||
setSelectedCategory(null);
|
||||
setModalVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Category delection failed. Try again.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-center ">
|
||||
<AdminMenu />
|
||||
<div className="md:w-3/4 p-3">
|
||||
<h1 className="text-2xl font-semibold mb-4">Manage Categories</h1>
|
||||
<CategoryForm
|
||||
value={name}
|
||||
setValue={setName}
|
||||
handleSubmit={handleCreateCategory}
|
||||
/>
|
||||
<br />
|
||||
<hr />
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
{categories?.map((category) => (
|
||||
<div key={category._id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
{
|
||||
setModalVisible(true);
|
||||
setSelectedCategory(category);
|
||||
setUpdatingName(category.name);
|
||||
}
|
||||
}}
|
||||
className="bg-white border border-pink-500 text-pink-500 py-2 px-4 rounded-lg m-3 hover:bg-pink-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-opacity-50"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={modalVisible} onClose={() => setModalVisible(false)}>
|
||||
<CategoryForm
|
||||
value={updatingName}
|
||||
setValue={(value) => setUpdatingName(value)}
|
||||
handleSubmit={handleUpdateCategory}
|
||||
buttonText="Update"
|
||||
handleDelete={handleDeleteCategory}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryList;
|
@ -0,0 +1,194 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
useCreateProductMutation,
|
||||
useUploadProductImageMutation,
|
||||
} from "../../redux/api/productApiSlice";
|
||||
import { useFetchCategoriesQuery } from "../../redux/api/categoryApiSlice";
|
||||
import { toast } from "react-toastify";
|
||||
import AdminMenu from "./AdminMenu";
|
||||
|
||||
const ProductList = () => {
|
||||
const [image, setImage] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [brand, setBrand] = useState("");
|
||||
const [stock, setStock] = useState(0);
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [useUploadProductImage] = useUploadProductImageMutation();
|
||||
const [createProduct] = useCreateProductMutation();
|
||||
const { data: categories } = useFetchCategoriesQuery();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const productData = new FormData();
|
||||
productData.append("image", image);
|
||||
productData.append("name", name);
|
||||
productData.append("description", description);
|
||||
productData.append("price", price);
|
||||
productData.append("category", category);
|
||||
productData.append("quantity", quantity);
|
||||
productData.append("brand", brand);
|
||||
productData.append("countInStock", stock);
|
||||
|
||||
const { data } = await createProduct(productData);
|
||||
|
||||
if (data.error) {
|
||||
toast.error("Product creation failed.");
|
||||
} else {
|
||||
toast.success(`${data.name} is created.`);
|
||||
navigate("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Product creation failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFileHandler = async (e) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", e.target.files[0]);
|
||||
|
||||
try {
|
||||
const res = await useUploadProductImage(formData).unwrap();
|
||||
toast.success(res.message);
|
||||
setImage(res.image);
|
||||
setImageUrl(res.image);
|
||||
} catch (error) {
|
||||
toast.error(error?.data?.message || error.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container xl:mx-[9rem] sm:mx-[0]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<AdminMenu />
|
||||
<div className="md:w-3/4 p-3">
|
||||
<div className="h-12">Create Product </div>
|
||||
|
||||
{imageUrl && (
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="product"
|
||||
className="block mx-auto max-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="border border-black px-4 block w-full text-center rounded-lg cursor-pointer font-bold py-11">
|
||||
{image ? image.name : "Upload Image"}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
onChange={uploadFileHandler}
|
||||
className={!image ? "hidden" : "text-white"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="flex flex-wrap gap-5 justify-center">
|
||||
<div className="one">
|
||||
<label htmlFor="name">Name</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="two">
|
||||
<label htmlFor="name block">Price</label> <br />
|
||||
<input
|
||||
type="number"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-5 justify-center">
|
||||
<div className="one">
|
||||
<label htmlFor="name block">Quantity</label> <br />
|
||||
<input
|
||||
type="number"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="two">
|
||||
<label htmlFor="name block">Brand</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label htmlFor="" className="my-5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
type="text"
|
||||
className="p-2 mb-3 border rounded-lg w-[100%]"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
></textarea>
|
||||
|
||||
<div className="flex justify-center gap-5">
|
||||
<div>
|
||||
<label htmlFor="name block">Count In Stock</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[34rem] border rounded-lg"
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="">Category</label>
|
||||
<select
|
||||
placeholder="Choose Category"
|
||||
className="p-4 mb-3 w-[34rem] border rounded-lg"
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option>--Pick One--</option>
|
||||
{categories?.map((c) => (
|
||||
<option key={c._id} value={c._id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="py-4 px-10 mt-5 rounded-lg text-lg fond-bold bg-pink-600"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductList;
|
@ -0,0 +1,261 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import AdminMenu from "./AdminMenu";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
useUpdateProductMutation,
|
||||
useDeleteProductMutation,
|
||||
useGetProductByIdQuery,
|
||||
useUploadProductImageMutation,
|
||||
} from "../../redux/api/productApiSlice";
|
||||
import { useFetchCategoriesQuery } from "../../redux/api/categoryApiSlice";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const AdminProductUpdate = () => {
|
||||
const params = useParams();
|
||||
|
||||
const { data: productData } = useGetProductByIdQuery(params._id);
|
||||
|
||||
console.log(productData);
|
||||
|
||||
const [image, setImage] = useState(productData?.image || "");
|
||||
const [name, setName] = useState(productData?.name || "");
|
||||
const [description, setDescription] = useState(
|
||||
productData?.description || ""
|
||||
);
|
||||
const [price, setPrice] = useState(productData?.price || "");
|
||||
const [category, setCategory] = useState(productData?.category || "");
|
||||
const [quantity, setQuantity] = useState(productData?.quantity || "");
|
||||
const [brand, setBrand] = useState(productData?.brand || "");
|
||||
const [stock, setStock] = useState(productData?.countInStock);
|
||||
|
||||
// hook
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch categories using RTK Query
|
||||
const { data: categories = [] } = useFetchCategoriesQuery();
|
||||
|
||||
const [uploadProductImage] = useUploadProductImageMutation();
|
||||
|
||||
// Define the update product mutation
|
||||
const [updateProduct] = useUpdateProductMutation();
|
||||
|
||||
// Define the delete product mutation
|
||||
const [deleteProduct] = useDeleteProductMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (productData && productData._id) {
|
||||
setName(productData.name);
|
||||
setDescription(productData.description);
|
||||
setPrice(productData.price);
|
||||
setCategory(productData.category?._id);
|
||||
setQuantity(productData.quantity);
|
||||
setBrand(productData.brand);
|
||||
setImage(productData.image);
|
||||
}
|
||||
}, [productData]);
|
||||
|
||||
const uploadFileHandler = async (e) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", e.target.files[0]);
|
||||
try {
|
||||
const res = await uploadProductImage(formData).unwrap();
|
||||
toast.success("Item added successfully", {
|
||||
position: toast.POSITION.TOP_RIGHT,
|
||||
autoClose: 2000,
|
||||
});
|
||||
setImage(res.image);
|
||||
} catch (err) {
|
||||
toast.success("Item added successfully", {
|
||||
position: toast.POSITION.TOP_RIGHT,
|
||||
autoClose: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", image);
|
||||
formData.append("name", name);
|
||||
formData.append("description", description);
|
||||
formData.append("price", price);
|
||||
formData.append("category", category);
|
||||
formData.append("quantity", quantity);
|
||||
formData.append("brand", brand);
|
||||
formData.append("countInStock", stock);
|
||||
|
||||
// Update product using the RTK Query mutation
|
||||
const data = await updateProduct({ productId: params._id, formData });
|
||||
|
||||
if (data?.error) {
|
||||
toast.error(data.error);
|
||||
} else {
|
||||
toast.success(`Product successfully updated`);
|
||||
navigate("/admin/allproductslist");
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toast.error("Product update failed. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
let answer = window.confirm(
|
||||
"Are you sure you want to delete this product?"
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
const { data } = await deleteProduct(params._id);
|
||||
toast.success(`"${data.name}" is deleted`, {
|
||||
position: toast.POSITION.TOP_RIGHT,
|
||||
autoClose: 2000,
|
||||
});
|
||||
navigate("/admin/allproductslist");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toast.error("Delete failed. Try again.", {
|
||||
position: toast.POSITION.TOP_RIGHT,
|
||||
autoClose: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container xl:mx-[9rem] sm:mx-[0]">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<AdminMenu />
|
||||
<div className="md:w-3/4 p-3">
|
||||
<div className="h-12">Update / Delete Product</div>
|
||||
|
||||
{image && (
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={image}
|
||||
alt="product"
|
||||
className="block mx-auto w-full h-[40%]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label className=" px-4 block w-full text-center rounded-lg cursor-pointer font-bold py-11">
|
||||
{image ? image.name : "Upload image"}
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
onChange={uploadFileHandler}
|
||||
className="text-white"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="flex flex-wrap">
|
||||
<div className="one">
|
||||
<label htmlFor="name">Name</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg mr-[5rem]"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="two">
|
||||
<label htmlFor="name block">Price</label> <br />
|
||||
<input
|
||||
type="number"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
<div>
|
||||
<label htmlFor="name block">Quantity</label> <br />
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg mr-[5rem]"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="name block">Brand</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg"
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label htmlFor="" className="my-5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
type="text"
|
||||
className="p-2 mb-3 border rounded-lg w-[95%]"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<label htmlFor="name block">Count In Stock</label> <br />
|
||||
<input
|
||||
type="text"
|
||||
className="p-4 mb-3 w-[34rem] border rounded-lg"
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="">Category</label> <br />
|
||||
<select
|
||||
placeholder="Choose Category"
|
||||
className="p-4 mb-3 w-[30rem] border rounded-lg mr-[5rem]"
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option>--Pick One--</option>
|
||||
{categories?.map((c) => (
|
||||
<option key={c._id} value={c._id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="py-4 px-10 mt-5 rounded-lg text-lg font-bold bg-green-600 mr-6"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="py-4 px-10 mt-5 rounded-lg text-lg font-bold bg-pink-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProductUpdate;
|
@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaTrash, FaEdit, FaCheck, FaTimes } from "react-icons/fa";
|
||||
import Loader from "../../components/Loader";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
useGetUsersQuery,
|
||||
useDeleteUserMutation,
|
||||
useUpdateUserMutation,
|
||||
} from "../../redux/api/usersApiSlice";
|
||||
import Message from "../../components/Message";
|
||||
import AdminMenu from "./AdminMenu";
|
||||
|
||||
const UserList = () => {
|
||||
const { data: users, refetch, isLoading, error } = useGetUsersQuery();
|
||||
|
||||
const [deleteUser] = useDeleteUserMutation();
|
||||
const [updateUser] = useUpdateUserMutation();
|
||||
|
||||
const [editableUserId, setEditableUserId] = useState(null);
|
||||
const [editableUserName, setEditableUserName] = useState("");
|
||||
const [editableUserEmail, setEditableUserEmail] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const deleteHandler = async (id) => {
|
||||
if (window.confirm("Are you sure?")) {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
alert("User Deleted Successfully");
|
||||
} catch (error) {
|
||||
toast.error(error.data.message || error.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEdit = (id, username, email) => {
|
||||
setEditableUserId(id);
|
||||
setEditableUserName(username);
|
||||
setEditableUserEmail(email);
|
||||
};
|
||||
|
||||
const updateHandler = async (id) => {
|
||||
try {
|
||||
await updateUser({
|
||||
userId: id,
|
||||
username: editableUserName,
|
||||
email: editableUserEmail,
|
||||
});
|
||||
|
||||
setEditableUserId(null);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(error.data.message || error.error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="p-4">
|
||||
<AdminMenu />
|
||||
<h1 className="text-2xl font-semibold mb-4 text-center">Users</h1>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<Message variant="danger">
|
||||
{error?.data?.message || error.message}
|
||||
</Message>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<table className="w-full md:w-4/5 mx-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">ID</th>
|
||||
<th className="px-4 py-2 text-left">NAME</th>
|
||||
<th className="px-4 py-2 text-left">EMAIL</th>
|
||||
<th className="px-4 py-2 text-left">ADMIN</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user._id}>
|
||||
<td className="px-4 py-2">{user._id}</td>
|
||||
<td className="px-4 py-2">
|
||||
{editableUserId === user._id ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={editableUserName}
|
||||
onChange={(e) => setEditableUserName(e.target.value)}
|
||||
className="w-full p-2 border rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateHandler(user._id)}
|
||||
className="ml-2 bg-blue-500 text-white py-2 px-4 rounded-lg"
|
||||
>
|
||||
<FaCheck />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
{user.username}{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleEdit(user._id, user.username, user.email)
|
||||
}
|
||||
>
|
||||
<FaEdit className="ml-[1rem]" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-2">
|
||||
{editableUserId === user._id ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={editableUserEmail}
|
||||
onChange={(e) => setEditableUserEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateHandler(user._id)}
|
||||
className="ml-2 bg-blue-500 text-white py-2 px-4 rounded-lg"
|
||||
>
|
||||
<FaCheck />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items center">
|
||||
<p>{user.email}</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleEdit(user._id, user.username, user.email)
|
||||
}
|
||||
>
|
||||
<FaEdit className="ml-[1rem]" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<td className="px-4 py-2">
|
||||
{user.isAdmin ? (
|
||||
<FaCheck style={{ color: "green" }} />
|
||||
) : (
|
||||
<FaTimes style={{ color: "red" }} />
|
||||
)}
|
||||
</td>
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-2">
|
||||
{!user.isAdmin && (
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => deleteHandler(user._id)}
|
||||
className="bg-red-600 hover:bg-red-700 text-white font-bold py-4 px-4 rounded"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserList;
|
@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useLoginMutation } from "../../redux/api/usersApiSlice";
|
||||
import { setCredientials } from "../../redux/features/auth/authSlice";
|
||||
import { toast } from "react-toastify";
|
||||
import Loader from "../../components/Loader";
|
||||
import { FaTrophy } from "react-icons/fa";
|
||||
|
||||
const Login = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [login, { isLoading }] = useLoginMutation();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const { search } = useLocation();
|
||||
const sp = new URLSearchParams(search);
|
||||
const redirect = sp.get("redirect") || "/";
|
||||
|
||||
useEffect(() => {
|
||||
if (userInfo) {
|
||||
navigate(redirect);
|
||||
}
|
||||
}, [navigate, redirect, userInfo]);
|
||||
|
||||
const submitHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const res = await login({ email, password }).unwrap();
|
||||
console.log(res);
|
||||
dispatch(setCredientials({ ...res }));
|
||||
} catch (error) {
|
||||
toast.error(error?.data?.message || error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<section className="pl-[10rem] flex flex-wrap">
|
||||
<div className="mr-[4rem] mt-[5rem]">
|
||||
<h1 className="text-2xl font-semibold mb-4">Login</h1>
|
||||
|
||||
<form onSubmit={submitHandler} className="container w-[40rem]">
|
||||
<div className="my-[2rem]">
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="my-[2rem]">
|
||||
<label htmlFor="password" className="block text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className="bg-pink-500 text-white px-4 py-2 rounded cursor-pointer my-[1rem]"
|
||||
>
|
||||
{isLoading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
{isLoading && <Loader />}
|
||||
</form>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
Create Account?{" "}
|
||||
<Link
|
||||
to={redirect ? `/register?redirect=${redirect}` : "/register"}
|
||||
className="text-pink-500 hover:underline"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
@ -0,0 +1,36 @@
|
||||
#navigation-container {
|
||||
width: 4%;
|
||||
transition: width 0.3s ease-in-out;
|
||||
overflow: hidden; /* Hide overflowing content during transition */
|
||||
}
|
||||
|
||||
#navigation-container:hover {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
/* Initially hide nav-item-name */
|
||||
.nav-item-name {
|
||||
display: none;
|
||||
transition: opacity 0.2s ease-in-out; /* Add opacity transition */
|
||||
}
|
||||
|
||||
#navigation-container:hover .nav-item-name {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out; /* Add opacity transition */
|
||||
}
|
||||
|
||||
#navigation-container:hover .search-input {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#navigation-container:hover .search-icon {
|
||||
display: none;
|
||||
transition: opacity 0.2s ease-in-out; /* Add opacity transition */
|
||||
}
|
@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AiOutlineHome,
|
||||
AiOutlineShopping,
|
||||
AiOutlineLogin,
|
||||
AiOutlineUserAdd,
|
||||
AiOutlineShoppingCart,
|
||||
} from "react-icons/ai";
|
||||
import { FaHeart } from "react-icons/fa";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./Navigation.css";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useLoginMutation, useLogoutMutation } from "../../redux/api/usersApiSlice";
|
||||
import { logout } from "../../redux/features/auth/authSlice";
|
||||
import FavoritesCount from "../Products/FavoritesCount";
|
||||
|
||||
|
||||
const Navigation = () => {
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
const { cartItems } = useSelector((state) => state.cart);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setDropdownOpen(!dropdownOpen);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setShowSidebar(!showSidebar);
|
||||
};
|
||||
|
||||
const closeSidebar = () => {
|
||||
setShowSidebar(false);
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [logoutApiCall] = useLogoutMutation();
|
||||
|
||||
const logoutHandler = async () => {
|
||||
try {
|
||||
await logoutApiCall().unwrap();
|
||||
dispatch(logout());
|
||||
navigate("/login");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ zIndex: 9999 }}
|
||||
className={`${
|
||||
showSidebar ? "hidden" : "flex"
|
||||
} xl:flex lg:flex md:hidden sm:hidden flex-col justify-between p-4 text-white bg-[#000] w-[4%] hover:w-[15%] h-[100vh] fixed `}
|
||||
id="navigation-container"
|
||||
>
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<AiOutlineHome className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">HOME</span>{" "}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/shop"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<AiOutlineShopping className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">SHOP</span>{" "}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/cart"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<AiOutlineShoppingCart className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">CART</span>{" "}
|
||||
|
||||
<div className="absolute top-9">
|
||||
{cartItems.length > 0 && (
|
||||
<span>
|
||||
<span className="px-1 py-0 text-sm text-white bg-pink-500 rounded-full">
|
||||
{cartItems.reduce((a, c) => a + c.qty, 0)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/favorite"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<FaHeart className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">FAVORITE</span>{" "}
|
||||
<FavoritesCount />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="flex items-center text-gray-8000 focus:outline-none"
|
||||
>
|
||||
{userInfo ? (
|
||||
<span className="text-white">{userInfo.username}</span>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{userInfo && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`h-4 w-4 ml-1 ${
|
||||
dropdownOpen ? "transform rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="white"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d={dropdownOpen ? "M5 15l7-7 7 7" : "M19 9l-7 7-7-7"}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{dropdownOpen && userInfo && (
|
||||
<ul
|
||||
className={`absolute right-0 mt-2 mr-14 space-y-2 bg-white text-gray-600 ${
|
||||
!userInfo.isAdmin ? "-top-20" : "-top-80"
|
||||
} `}
|
||||
>
|
||||
{userInfo.isAdmin && (
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin/productlist"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin/categorylist"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Category
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin/orderlist"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Orders
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/admin/userlist"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Users
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
<li>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
onClick={logoutHandler}
|
||||
className="block px-4 py-2 hover:bg-gray-100 hover:text-cyan-500"
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!userInfo && (
|
||||
<ul>
|
||||
<li>
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<AiOutlineLogin className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">Login</span>{" "}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center transition-transform transform hover:translate-x-2"
|
||||
>
|
||||
<AiOutlineUserAdd className="mr-2 mt-[3rem]" size={26} />
|
||||
<span className="hidden nav-item-name mt-[3rem]">
|
||||
Register
|
||||
</span>{" "}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import Loader from "../../components/Loader";
|
||||
import { setCredientials } from "../../redux/features/auth/authSlice";
|
||||
import { toast } from "react-toastify";
|
||||
import { useRegisterMutation } from "../../redux/api/usersApiSlice";
|
||||
|
||||
const Register = () => {
|
||||
const [username, setUserName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [register, { isLoading }] = useRegisterMutation();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const { search } = useLocation();
|
||||
const sp = new URLSearchParams(search);
|
||||
const redirect = sp.get("redirect") || "/";
|
||||
|
||||
useEffect(() => {
|
||||
if (userInfo) {
|
||||
navigate(redirect);
|
||||
}
|
||||
}, [navigate, redirect, userInfo]);
|
||||
|
||||
const submitHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Password and Confim Password do not match");
|
||||
} else {
|
||||
try {
|
||||
const res = await register({ username, email, password }).unwrap();
|
||||
dispatch(setCredientials({ ...res }));
|
||||
navigate(redirect);
|
||||
toast.success("User successfully registered");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error(error.data.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<section className="pl-[10rem] flex flex-wrap">
|
||||
<div className="mr-[4rem] mt-[5rem]">
|
||||
<h1 className="text-2xl font-semibold mb-4">Register</h1>
|
||||
|
||||
<form onSubmit={submitHandler} className="container w-[40rem]">
|
||||
<div className="my-[2rem]">
|
||||
<label htmlFor="name" className="block text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={username}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-[2rem]">
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-[2rem]">
|
||||
<label htmlFor="password" className="block text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-[2rem]">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
className="mt-1 p-2 border border-slate-600 rounded w-full"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className="bg-pink-500 text-white px-4 py-2 rounded cursor-pointer my-[1rem]"
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? "Registering..." : "Register"}
|
||||
</button>
|
||||
|
||||
{isLoading && <Loader />}
|
||||
</form>
|
||||
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
Already have an account? {" "}
|
||||
<Link
|
||||
to={redirect ? `/login?redirect=${redirect}` : "/login"}
|
||||
className="text-pink-500 hover:underline"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
@ -0,0 +1,115 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaTrash } from "react-icons/fa";
|
||||
import { addToCart, removeFromCart } from "../redux/features/cart/cartSlice";
|
||||
|
||||
const Cart = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const cart = useSelector((state) => state.cart);
|
||||
const { cartItems } = cart;
|
||||
|
||||
const addToCartHandler = (product, qty) => {
|
||||
dispatch(addToCart({ ...product, qty }));
|
||||
};
|
||||
|
||||
const removeFromCartHandler = (id) => {
|
||||
dispatch(removeFromCart(id));
|
||||
};
|
||||
|
||||
const checkoutHandler = () => {
|
||||
navigate("/login?redirect=/shipping");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container flex justify-around items-start wrap mx-auto mt-8">
|
||||
{cartItems.length === 0 ? (
|
||||
<div>
|
||||
Your cart is empty <Link to="/shop">Go To Shop</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col w-[80%]">
|
||||
<h1 className="text-2xl font-semibold mb-4">Shopping Cart</h1>
|
||||
|
||||
{cartItems.map((item) => (
|
||||
<div key={item._id} className="flex items-enter mb-[1rem] pb-2">
|
||||
<div className="w-[5rem] h-[5rem]">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 ml-4">
|
||||
<Link to={`/product/${item._id}`} className="text-pink-500">
|
||||
{item.name}
|
||||
</Link>
|
||||
|
||||
<div className="mt-2">{item.brand}</div>
|
||||
<div className="mt-2 font-bold">
|
||||
$ {item.price}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
<select
|
||||
className="w-full p-1 border rounded text-black"
|
||||
value={item.qty}
|
||||
onChange={(e) =>
|
||||
addToCartHandler(item, Number(e.target.value))
|
||||
}
|
||||
>
|
||||
{[...Array(item.countInStock).keys()].map((x) => (
|
||||
<option key={x + 1} value={x + 1}>
|
||||
{x + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="text-red-500 mr-[5rem]"
|
||||
onClick={() => removeFromCartHandler(item._id)}
|
||||
>
|
||||
<FaTrash className="ml-[1rem] mt-[.5rem]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-8 w-[40rem]">
|
||||
<div className="p-4 rounded-lg">
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Items ({cartItems.reduce((acc, item) => acc + item.qty, 0)})
|
||||
</h2>
|
||||
|
||||
<div className="text-2xl font-bold">
|
||||
${" "}
|
||||
{cartItems
|
||||
.reduce((acc, item) => acc + item.qty * item.price, 0)
|
||||
.toFixed(2)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="bg-pink-500 mt-4 py-2 px-4 rounded-full text-lg w-full"
|
||||
disabled={cartItems.length === 0}
|
||||
onClick={checkoutHandler}
|
||||
>
|
||||
Proceed To Checkout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cart;
|
@ -0,0 +1,75 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js";
|
||||
import { useSelector } from "react-redux";
|
||||
import { toast } from "react-toastify";
|
||||
import Message from "../../components/Message";
|
||||
import Loader from "../../components/Loader";
|
||||
import {
|
||||
useDeliverOrderMutation,
|
||||
useGetOrderDetailsQuery,
|
||||
useGetPaypalClientIdQuery,
|
||||
usePayOrderMutation,
|
||||
} from "../../redux/api/orderApiSlice";
|
||||
|
||||
const Order = () => {
|
||||
const { id: orderId } = useParams();
|
||||
const {
|
||||
data: order,
|
||||
refetch,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetOrderDetailsQuery(orderId);
|
||||
const [payOrder, { isLoading: loadingPay }] = usePayOrderMutation();
|
||||
const [deliverOrder, { isLoading: loadingDeliver }] = useDeliverOrderMutation();
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [{ isPending }, paypalDispatch] = usePayPalScriptReducer();
|
||||
|
||||
const {
|
||||
data: paypal,
|
||||
isLoading: loadingPaPal,
|
||||
error: errorPayPal,
|
||||
} = useGetPaypalClientIdQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!errorPayPal && !loadingPaPal && paypal.clientId) {
|
||||
const loadingPaPalScript = async () => {
|
||||
paypalDispatch({
|
||||
type: "resetOptions",
|
||||
value: {
|
||||
"client-id": paypal.clientId,
|
||||
currency: "USD",
|
||||
},
|
||||
}),
|
||||
paypalDispatch({ type: "setLoadingStatus", valie: "pending" });
|
||||
};
|
||||
|
||||
if (order && !order.isPaid) {
|
||||
if (!window.paypal) {
|
||||
loadingPaPalScript();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [errorPayPal, loadingPaPal, order, paypal, paypalDispatch]);
|
||||
|
||||
return isLoading ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<Message variant="danger">{error.data.message}</Message>
|
||||
) : (
|
||||
<div className="container flex flex-col ml-[10rem] md:flex-row">
|
||||
<div className="md:w-2/3 pr-4">
|
||||
<div className="border gray-300 mt-5 pb-4 mb-5">
|
||||
{order.orderItems.length === 0 ? (
|
||||
<Message> Order is Empty </Message>
|
||||
) : (
|
||||
<h1>Hello</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Order;
|
@ -0,0 +1,145 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import Message from "../../components/Message";
|
||||
import ProgressSteps from "../../components/ProgressSteps";
|
||||
import Loader from "../../components/Loader";
|
||||
import { useCreateOrderMutation } from "../../redux/api/orderApiSlice";
|
||||
import { clearCartItems } from "../../redux/features/cart/cartSlice";
|
||||
|
||||
const PlaceOrder = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const cart = useSelector((state) => state.cart);
|
||||
|
||||
const [createOrder, { isLoading, error }] = useCreateOrderMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!cart.shippingAddress.address) {
|
||||
navigate("/shipping");
|
||||
}
|
||||
}, [cart.paymentMethod, cart.shippingAddress.address, navigate]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const placeOrderHandler = async () => {
|
||||
try {
|
||||
const res = await createOrder({
|
||||
orderItems: cart.cartItems,
|
||||
shippingAddress: cart.shippingAddress,
|
||||
paymentMethod: cart.paymentMethod,
|
||||
itemsPrice: cart.itemsPrice,
|
||||
shippingPrice: cart.shippingPrice,
|
||||
taxPrice: cart.taxPrice,
|
||||
totalPrice: cart.totalPrice,
|
||||
}).unwrap();
|
||||
dispatch(clearCartItems());
|
||||
navigate(`/order/${res._id}`);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressSteps step1 step2 step3 />
|
||||
|
||||
<div className="container mx-auto mt-8 px-20 ">
|
||||
{cart.cartItems.length === 0 ? (
|
||||
<Message>Your cart is empty</Message>
|
||||
) : (
|
||||
<div className="overflow-x-auto mx-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="px-1 py-2 text-left align-top">Image</td>
|
||||
<td className="px-1 py-2 text-left">Product</td>
|
||||
<td className="px-1 py-2 text-left">Quantity</td>
|
||||
<td className="px-1 py-2 text-left">Price</td>
|
||||
<td className="px-1 py-2 text-left">Total</td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{cart.cartItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="p-2">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-16 h-16 object-cover"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="p-2">
|
||||
<Link to={`/product/${item.product}`}>{item.name}</Link>
|
||||
</td>
|
||||
<td className="p-2">{item.qty}</td>
|
||||
<td className="p-2">{item.price.toFixed(2)}</td>
|
||||
<td className="p-2">
|
||||
$ {(item.qty * item.price).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-semibold mb-5">Order Summary</h2>
|
||||
<div className="flex justify-between flex-wrap p-8">
|
||||
<ul className="text-lg">
|
||||
<li>
|
||||
<span className="font-semibold mb-4">Items:</span> $
|
||||
{cart.itemsPrice}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold mb-4">Shipping:</span> $
|
||||
{cart.shippingPrice}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold mb-4">Tax:</span> $
|
||||
{cart.taxPrice}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold mb-4">Total:</span> $
|
||||
{cart.totalPrice}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{error && <Message variant="danger">{error.data.message}</Message>}
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Shipping</h2>
|
||||
<p>
|
||||
<strong>Address:</strong> {cart.shippingAddress.address},{" "}
|
||||
{cart.shippingAddress.city} {cart.shippingAddress.postalCode},{" "}
|
||||
{cart.shippingAddress.country}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Payment Method</h2>
|
||||
<strong>Method:</strong> {cart.paymentMethod}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="bg-pink-500 text-white py-2 px-4 rounded-full text-lg w-full mt-4"
|
||||
disabled={cart.cartItems === 0}
|
||||
onClick={placeOrderHandler}
|
||||
>
|
||||
Place Order
|
||||
</button>
|
||||
|
||||
{isLoading && <Loader />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceOrder;
|
@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
saveShippingAddress,
|
||||
savePaymentMethod,
|
||||
} from "../../redux/features/cart/cartSlice";
|
||||
import ProgressSteps from "../../components/ProgressSteps.jsx";
|
||||
|
||||
const Shipping = () => {
|
||||
const cart = useSelector((state) => state.cart);
|
||||
const { shippingAddress } = cart;
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState("PayPal");
|
||||
const [address, setAddress] = useState(shippingAddress.address || "");
|
||||
const [city, setCity] = useState(shippingAddress.city || "");
|
||||
const [postalCode, setPostalCode] = useState(
|
||||
shippingAddress.postalCode || ""
|
||||
);
|
||||
const [country, setCountry] = useState(shippingAddress.country || "");
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submitHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(saveShippingAddress({ address, city, postalCode, country }));
|
||||
dispatch(savePaymentMethod(paymentMethod));
|
||||
navigate("/placeorder");
|
||||
};
|
||||
|
||||
// Payment
|
||||
useEffect(() => {
|
||||
if (!shippingAddress.address) {
|
||||
navigate("/shipping");
|
||||
}
|
||||
}, [navigate, shippingAddress]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto mt-10">
|
||||
<ProgressSteps step1 step2 />
|
||||
<div className="mt-[10rem] flex justify-around items-center flex-wrap">
|
||||
<form onSubmit={submitHandler} className="w-[40rem]">
|
||||
<h1 className="text-2xl font-semibold mb-4">Shipping</h1>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Enter address"
|
||||
value={address}
|
||||
required
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">City</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Enter city"
|
||||
value={city}
|
||||
required
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Enter postal code"
|
||||
value={postalCode}
|
||||
required
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Enter country"
|
||||
value={country}
|
||||
required
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-500">Select Method</label>
|
||||
<div className="mt-2">
|
||||
<label className="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
className="form-radio text-pink-500"
|
||||
name="paymentMethod"
|
||||
value="PayPal"
|
||||
checked={paymentMethod === "PayPal"}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
/>
|
||||
|
||||
<span className="ml-2">PayPal</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="bg-pink-500 text-white py-2 px-4 rounded-full text-lg w-full"
|
||||
type="submit"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shipping;
|
@ -0,0 +1,23 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectFavoriteProduct } from "../../redux/features/favorites/favoriteSlice";
|
||||
import Product from "./Product";
|
||||
|
||||
const Favorites = () => {
|
||||
const favorites = useSelector(selectFavoriteProduct);
|
||||
|
||||
return (
|
||||
<div className="ml-[10rem]">
|
||||
<h1 className="text-lg font-bold ml-[3rem] mt-[3rem]">
|
||||
FAVORITE PRODUCTS
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
{favorites.map((product) => (
|
||||
<Product key={product._id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Favorites;
|
@ -0,0 +1,18 @@
|
||||
import { useSelector } from "react-redux/es/hooks/useSelector";
|
||||
|
||||
const FavoritesCount = () => {
|
||||
const favorites = useSelector((state) => state.favorites);
|
||||
const favoriteCount = favorites.length;
|
||||
|
||||
return (
|
||||
<div className="absolute left-2 top-8">
|
||||
{favoriteCount > 0 && (
|
||||
<span className="px-1 py-0 text-sm text-white bg-pink-500 rounded-full">
|
||||
{favoriteCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesCount;
|
@ -0,0 +1,49 @@
|
||||
import { FaHeart, FaRegHeart } from "react-icons/fa";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import {
|
||||
addToFavorites,
|
||||
removeFromFavorites,
|
||||
setFavorites,
|
||||
} from "../../redux/features/favorites/favoriteSlice";
|
||||
import {
|
||||
addFavoritesToLocalStorage,
|
||||
removeFavoritesFromLocalStorage,
|
||||
getFavoritesFromLocalStorage,
|
||||
} from "../../Utils/localStorage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const HeartIcon = ({ product }) => {
|
||||
const dispatch = useDispatch();
|
||||
const favorites = useSelector((state) => state.favorites) || [];
|
||||
const isFavorite = favorites.some((p) => p._id === product._id);
|
||||
|
||||
useEffect(() => {
|
||||
const favoritesFromLocalStorage = getFavoritesFromLocalStorage();
|
||||
dispatch(setFavorites(favoritesFromLocalStorage));
|
||||
}, []);
|
||||
|
||||
const toggleFavorites = () => {
|
||||
if(isFavorite){
|
||||
dispatch(removeFromFavorites(product))
|
||||
// remove the product from localstorage
|
||||
removeFromFavorites(product._id)
|
||||
} else {
|
||||
dispatch(addToFavorites(product))
|
||||
// add the product to localStorage
|
||||
addFavoritesToLocalStorage(product)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={toggleFavorites} className="absolute top-2 right-5 cursor-pointer">
|
||||
{isFavorite ? (
|
||||
<FaHeart className="text-pink-500" />
|
||||
) : (
|
||||
<FaRegHeart className="text-white"/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeartIcon;
|
@ -0,0 +1,30 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import HeartIcon from "./HeartIcon";
|
||||
const Product = ({ product }) => {
|
||||
return (
|
||||
<div className="w-[30rem] ml-[2rem] p-3 relative">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-[30rem] rounded"
|
||||
/>
|
||||
|
||||
<HeartIcon product={product} />
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<Link to={`/product/${product._id}`}>
|
||||
<h2 className="flex justify-between items-center">
|
||||
<div className="text-lg">{product.name}</div>
|
||||
<span className="bg-pink-100 text-pink-800 text-sm font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-pink-900 dark:text-pink-300">
|
||||
$ {product.price}
|
||||
</span>
|
||||
</h2>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Product;
|
@ -0,0 +1,87 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AiOutlineShoppingCart } from "react-icons/ai";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { addToCart } from "../../redux/features/cart/cartSlice";
|
||||
import { toast } from "react-toastify";
|
||||
import HeartIcon from "./HeartIcon";
|
||||
|
||||
const ProductCard = ({ p }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const addToCartHandler = (product, qty) => {
|
||||
dispatch(addToCart({ ...product, qty }));
|
||||
toast.success("Item added successfully", {
|
||||
position: toast.POSITION.TOP_RIGHT,
|
||||
autoClose: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-sm relative rounded-lg shadow">
|
||||
<section className="relative">
|
||||
<Link to={`/product/${p._id}`}>
|
||||
<span className="absolute bottom-3 right-3 bg-pink-100 text-pink-800 text-sm font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-pink-900 dark:text-pink-300">
|
||||
{p?.brand}
|
||||
</span>
|
||||
<img
|
||||
className="cursor-pointer w-full"
|
||||
src={p.image}
|
||||
alt={p.name}
|
||||
style={{ height: "170px", objectFit: "cover" }}
|
||||
/>
|
||||
</Link>
|
||||
<HeartIcon product={p} />
|
||||
</section>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between">
|
||||
<h5 className="mb-2 text-xl text-whiet dark:text-white">{p?.name}</h5>
|
||||
|
||||
<p className="font-semibold text-pink-500">
|
||||
{p?.price?.toLocaleString("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 font-normal">
|
||||
{p?.description?.substring(0, 60)} ...
|
||||
</p>
|
||||
|
||||
<section className="flex justify-between items-center">
|
||||
<Link
|
||||
to={`/product/${p._id}`}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-pink-700 rounded-lg hover:bg-pink-800 focus:ring-4 focus:outline-none focus:ring-pink-300 dark:bg-pink-600 dark:hover:bg-pink-700 dark:focus:ring-pink-800"
|
||||
>
|
||||
Read More
|
||||
<svg
|
||||
className="w-3.5 h-3.5 ml-2"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 10"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M1 5h12m0 0L9 1m4 4L9 9"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full"
|
||||
onClick={() => addToCartHandler(p, 1)}
|
||||
>
|
||||
<AiOutlineShoppingCart size={25} />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
@ -0,0 +1,108 @@
|
||||
import { useGetTopProductQuery } from "../../redux/api/productApiSlice";
|
||||
import Message from "../../components/Message";
|
||||
import Slider from "react-slick";
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import moment from "moment";
|
||||
import {
|
||||
FaBox,
|
||||
FaClock,
|
||||
FaShoppingCart,
|
||||
FaStar,
|
||||
FaStore,
|
||||
} from "react-icons/fa";
|
||||
|
||||
const ProductCarousel = () => {
|
||||
const { data: products, isLoading, error } = useGetTopProductQuery();
|
||||
const settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
arrows: true,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 5000,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 xl:block lg:block md:block">
|
||||
{isLoading ? null : error ? (
|
||||
<Message variant="danger">
|
||||
{error?.data?.message || error.message}
|
||||
</Message>
|
||||
) : (
|
||||
<Slider
|
||||
{...settings}
|
||||
className="xl:w-[50rem] lg:w-[50rem] md:-=[56rem] sm:w-[40rem] sm:block"
|
||||
>
|
||||
{products.map(
|
||||
({
|
||||
image,
|
||||
_id,
|
||||
name,
|
||||
price,
|
||||
description,
|
||||
brand,
|
||||
createdAt,
|
||||
numReviews,
|
||||
rating,
|
||||
quantity,
|
||||
countInStock,
|
||||
}) => (
|
||||
<div key={_id}>
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
className="w-full rounded-lg object-cover h-[30rem]"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="one">
|
||||
<h2>{name}</h2>
|
||||
<p>$ {price}</p>
|
||||
<p className="pt-2 w-25rem">
|
||||
{description.substring(0, 170)}...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div className="one">
|
||||
<h1 className="flex items-center mb-6 w-[12rem]">
|
||||
<FaStore className="mr-2" /> Brand: {brand}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[12rem]">
|
||||
<FaClock className="mr-2" /> Added:{" "}
|
||||
{moment(createdAt).fromNow()}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[12rem]">
|
||||
<FaStar className="mr-2" /> Reviews: {numReviews}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="two">
|
||||
<h1 className="flex items-center mb-6 w-[10rem]">
|
||||
<FaStar className="mr-2" /> Ratings:{" "}
|
||||
{Math.round(rating)}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[10rem]">
|
||||
<FaShoppingCart className="mr-2" /> Quantity:{" "}
|
||||
{quantity}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[10rem]">
|
||||
<FaBox className="mr-2" /> In Stock:{" "}
|
||||
{countInStock}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Slider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCarousel;
|
@ -0,0 +1,188 @@
|
||||
import { useState } from "react";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
useGetProductDetailsQuery,
|
||||
useCreateReviewMutation,
|
||||
} from "../../redux/api/productApiSlice";
|
||||
import Loader from "../../components/Loader";
|
||||
import Message from "../../components/Message";
|
||||
import {
|
||||
FaBox,
|
||||
FaClock,
|
||||
FaShoppingCart,
|
||||
FaStar,
|
||||
FaStore,
|
||||
} from "react-icons/fa";
|
||||
import moment from "moment";
|
||||
import HeartIcon from "./HeartIcon";
|
||||
import Ratings from "./Ratings.jsx";
|
||||
import ProductTabs from "./ProductTabs";
|
||||
import { addToCart } from "../../redux/features/cart/cartSlice";
|
||||
|
||||
const ProductDetails = () => {
|
||||
const { id: productId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [qty, setQty] = useState(1);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
const {
|
||||
data: product,
|
||||
isLoading,
|
||||
refetch,
|
||||
error,
|
||||
} = useGetProductDetailsQuery(productId);
|
||||
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [createReview, { isLoading: loadingProductReview }] =
|
||||
useCreateReviewMutation();
|
||||
|
||||
const submitHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await createReview({
|
||||
productId,
|
||||
rating,
|
||||
comment,
|
||||
}).unwrap();
|
||||
refetch();
|
||||
toast.success("Review created successfully");
|
||||
} catch (error) {
|
||||
toast.error(error?.data || error.message);
|
||||
}
|
||||
};
|
||||
const addToCartHandler = () => {
|
||||
dispatch(addToCart({ ...product, qty }));
|
||||
navigate("/cart");
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Link
|
||||
to="/"
|
||||
className="font-semibold hover:underline ml-[10rem]"
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<Message variant="danger">
|
||||
{error?.data?.message || error.message}
|
||||
</Message>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap relative items-between mt-[2rem] ml-[10rem]">
|
||||
<div>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full xl:w-[50rem] lg:w-[45rem] md:w-[30rem] sm:w-[20rem] mr-[2rem]"
|
||||
/>
|
||||
|
||||
<HeartIcon product={product} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between">
|
||||
<h2 className="text-2xl font-semibold">{product.name}</h2>
|
||||
<p className="my-4 xl:w-[35rem] lg:w-[35rem] md:w-[30rem] text-[#B0B0B0]">
|
||||
{product.description}
|
||||
</p>
|
||||
|
||||
<p className="text-5xl my-4 font-extrabold">$ {product.price}</p>
|
||||
|
||||
<div className="flex items-center justify-between w-[20rem]">
|
||||
<div className="one">
|
||||
<h1 className="flex items-center mb-6">
|
||||
<FaStore className="mr-2" /> Brand:{" "}
|
||||
{product.brand}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[20rem]">
|
||||
<FaClock className="mr-2" /> Added:{" "}
|
||||
{moment(product.createAt).fromNow()}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6">
|
||||
<FaStar className="mr-2" /> Reviews:{" "}
|
||||
{product.numReviews}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="two">
|
||||
<h1 className="flex items-center mb-6">
|
||||
<FaStar className="mr-2" /> Ratings: {rating}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6">
|
||||
<FaShoppingCart className="mr-2" /> Quantity:{" "}
|
||||
{product.quantity}
|
||||
</h1>
|
||||
<h1 className="flex items-center mb-6 w-[10rem]">
|
||||
<FaBox className="mr-2" /> In Stock:{" "}
|
||||
{product.countInStock}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between flex-wrap">
|
||||
<Ratings
|
||||
value={product.rating}
|
||||
text={`${product.numReviews} reviews`}
|
||||
/>
|
||||
|
||||
{product.countInStock > 0 && (
|
||||
<div>
|
||||
<select
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
className="p-2 w-[6rem] rounded-lg text-black"
|
||||
>
|
||||
{[...Array(product.countInStock).keys()].map((x) => (
|
||||
<option key={x + 1} value={x + 1}>
|
||||
{x + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="btn-container">
|
||||
<button
|
||||
onClick={addToCartHandler}
|
||||
disabled={product.countInStock === 0}
|
||||
className="bg-pink-600 text-white py-2 px-4 rounded-lg mt-4 md:mt-0"
|
||||
>
|
||||
Add To Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[5rem] container flex flex-wrap items-start justify-between ml-[10rem]">
|
||||
<ProductTabs
|
||||
loadingProductReview={loadingProductReview}
|
||||
userInfo={userInfo}
|
||||
submitHandler={submitHandler}
|
||||
rating={rating}
|
||||
setRating={setRating}
|
||||
comment={comment}
|
||||
setComment={setComment}
|
||||
product={product}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetails;
|
@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Ratings from "./Ratings";
|
||||
import { useGetTopProductQuery } from "../../redux/api/productApiSlice";
|
||||
import SmallProduct from "./SmallProduct";
|
||||
import Loader from "../../components/Loader";
|
||||
|
||||
const ProductTabs = ({
|
||||
loadingProductReview,
|
||||
userInfo,
|
||||
submitHandler,
|
||||
rating,
|
||||
setRating,
|
||||
comment,
|
||||
setComment,
|
||||
product,
|
||||
}) => {
|
||||
const { data, isLoading } = useGetTopProductQuery();
|
||||
|
||||
const [activeTab, setActiveTab] = useState(1);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const handleTabClick = (tabNumber) => {
|
||||
setActiveTab(tabNumber);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<section className="mr-[5rem]">
|
||||
<div
|
||||
className={`flex-1 p-4 cursor-pointer text-lg ${
|
||||
activeTab === 1 ? "font-bold" : ""
|
||||
}`}
|
||||
onClick={() => handleTabClick(1)}
|
||||
>
|
||||
Write Your Review
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-4 cursor-pointer text-lg ${
|
||||
activeTab === 2 ? "font-bold" : ""
|
||||
}`}
|
||||
onClick={() => handleTabClick(2)}
|
||||
>
|
||||
All Reviews
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-4 cursor-pointer text-lg ${
|
||||
activeTab === 3 ? "font-bold" : ""
|
||||
}`}
|
||||
onClick={() => handleTabClick(3)}
|
||||
>
|
||||
Related Products
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Second Part */}
|
||||
<section>
|
||||
{activeTab === 1 && (
|
||||
<div className="mt-4">
|
||||
{userInfo ? (
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className="my-2">
|
||||
<label htmlFor="rating" className="block text-xl mb-2">
|
||||
Rating
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="rating"
|
||||
required
|
||||
value={rating}
|
||||
onChange={(e) => setRating(e.target.value)}
|
||||
className="p-2 border rounded-lg xl:w-[40rem] text-black"
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="1">Inferior</option>
|
||||
<option value="2">Decent</option>
|
||||
<option value="3">Great</option>
|
||||
<option value="4">Excellent</option>
|
||||
<option value="5">Exceptional</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="my-2">
|
||||
<label htmlFor="comment" className="block text-xl mb-2">
|
||||
Comment
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="comment"
|
||||
rows="3"
|
||||
required
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
className="p-2 border rounded-lg xl:w-[40rem] text-black"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loadingProductReview}
|
||||
className="bg-pink-600 text-white py-2 px-4 rounded-lg"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p>
|
||||
Please <Link to="/login">sign in</Link> to write a review
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{activeTab === 2 && (
|
||||
<>
|
||||
<div>{product.reviews.length === 0 && <p>No Reviews</p>}</div>
|
||||
|
||||
<div>
|
||||
{product.reviews.map((review) => (
|
||||
<div
|
||||
key={review._id}
|
||||
className="bg-gray-100 p-4 rounded-lg xl:ml-[2rem] sm:ml-[0rem] xl:w-[50rem] sm:w-[24rem] mb-5"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<strong className="text-[#B0B0B0]">{review.name}</strong>
|
||||
<p className="text-[#B0B0B0]">
|
||||
{review.createdAt.substring(0, 10)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="my-4">{review.comment}</p>
|
||||
<Ratings value={review.rating} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{activeTab === 3 && (
|
||||
<section className="ml-[4rem] flex flex-wrap">
|
||||
{!data ? (
|
||||
<Loader />
|
||||
) : (
|
||||
data.map((product) => (
|
||||
<div key={product._id}>
|
||||
<SmallProduct product={product} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTabs;
|
@ -0,0 +1,30 @@
|
||||
import { FaRegStar, FaStar, FaStarHalfAlt } from "react-icons/fa";
|
||||
|
||||
const Ratings = ({ value, text, color }) => {
|
||||
const fullStars = Math.floor(value);
|
||||
const halfStars = value - fullStars > 0.5 ? 1 : 0;
|
||||
const emptyStar = 5 - fullStars - halfStars;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{[...Array(fullStars)].map((_, index) => (
|
||||
<FaStar key={index} className={`text-${color} ml-1`} />
|
||||
))}
|
||||
|
||||
{halfStars === 1 && <FaStarHalfAlt className={`text-${color} ml-1`} />}
|
||||
{[...Array(emptyStar)].map((_, index) => (
|
||||
<FaRegStar key={index} className={`text-${color} ml-1`} />
|
||||
))}
|
||||
|
||||
<span className={`rating-text ml-{2rem} text-${color}`}>
|
||||
{text && text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Ratings.defaultProps = {
|
||||
color: "yellow-500",
|
||||
};
|
||||
|
||||
export default Ratings;
|
@ -0,0 +1,32 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import HeartIcon from './HeartIcon'
|
||||
|
||||
const SmallProduct = ({ product }) => {
|
||||
return (
|
||||
<div className="w-[20rem] ml-[2rem] p-3">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="h-auto rounded"
|
||||
/>
|
||||
<HeartIcon product={product} />
|
||||
<div className="p-54">
|
||||
<Link to={`/product/${product._id}`}>
|
||||
<h2 className="flex justify-between items-center ">
|
||||
<div>
|
||||
{product.name}
|
||||
<span className="bg-pink-100 text-pink-800 text-sm font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-pink-900 dark:text-pink-300">
|
||||
${product.price}
|
||||
</span>
|
||||
</div>
|
||||
</h2>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default SmallProduct
|
@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useGetFilteredProductsQuery } from "../redux/api/productApiSlice";
|
||||
import { useFetchCategoriesQuery } from "../redux/api/categoryApiSlice";
|
||||
|
||||
import {
|
||||
setCategories,
|
||||
setProducts,
|
||||
setChecked,
|
||||
} from "../redux/features/shop/shopSlice";
|
||||
import Loader from "../components/Loader";
|
||||
import ProductCard from "./Products/ProductCard.jsx";
|
||||
|
||||
const Shop = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { categories, products, checked, radio } = useSelector(
|
||||
(state) => state.shop
|
||||
);
|
||||
|
||||
const categoriesQuery = useFetchCategoriesQuery();
|
||||
const [priceFilter, setPriceFilter] = useState("");
|
||||
|
||||
const filteredProductsQuery = useGetFilteredProductsQuery({
|
||||
checked,
|
||||
radio,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoriesQuery.isLoading) {
|
||||
dispatch(setCategories(categoriesQuery.data));
|
||||
}
|
||||
}, [categoriesQuery.data, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!checked.length || !radio.length) {
|
||||
if (!filteredProductsQuery.isLoading) {
|
||||
// Filter products based on both checked categories and price filter
|
||||
const filteredProducts = filteredProductsQuery.data.filter(
|
||||
(product) => {
|
||||
// Check if the product price includes the entered price filter value
|
||||
return (
|
||||
product.price.toString().includes(priceFilter) ||
|
||||
product.price === parseInt(priceFilter, 10)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
dispatch(setProducts(filteredProducts));
|
||||
}
|
||||
}
|
||||
}, [checked, radio, filteredProductsQuery.data, dispatch, priceFilter]);
|
||||
|
||||
const handleBrandClick = (brand) => {
|
||||
const productsByBrand = filteredProductsQuery.data?.filter(
|
||||
(product) => product.brand === brand
|
||||
);
|
||||
dispatch(setProducts(productsByBrand));
|
||||
};
|
||||
|
||||
const handleCheck = (value, id) => {
|
||||
const updatedChecked = value
|
||||
? [...checked, id]
|
||||
: checked.filter((c) => c !== id);
|
||||
dispatch(setChecked(updatedChecked));
|
||||
};
|
||||
|
||||
// Add "All Brands" option to uniqueBrands
|
||||
const uniqueBrands = [
|
||||
...Array.from(
|
||||
new Set(
|
||||
filteredProductsQuery.data
|
||||
?.map((product) => product.brand)
|
||||
.filter((brand) => brand !== undefined)
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
const handlePriceChange = (e) => {
|
||||
// Update the price filter state when the user types in the input filed
|
||||
setPriceFilter(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container mx-auto">
|
||||
<div className="flex md:flex-row">
|
||||
<div className="p-3 mt-2 mb-2">
|
||||
<h2 className="h4 text-center py-2 bg-gray-300 rounded-full mb-2">
|
||||
Filter by Categories
|
||||
</h2>
|
||||
|
||||
<div className="p-5 w-[15rem]">
|
||||
{categories?.map((c) => (
|
||||
<div key={c._id} className="mb-2">
|
||||
<div className="flex ietms-center mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="red-checkbox"
|
||||
onChange={(e) => handleCheck(e.target.checked, c._id)}
|
||||
className="w-4 h-4 text-pink-600 bg-gray-100 border-gray-300 rounded focus:ring-pink-500 dark:focus:ring-pink-600 dark:ring-offset-gray-800 focus:ring-2 dark:border-gray-600"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="pink-checkbox"
|
||||
className="ml-2 text-sm font-medium dark:text-gray-300"
|
||||
>
|
||||
{c.name}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="h4 text-center py-2 bg-gray-300 rounded-full mb-2">
|
||||
Filter by Brands
|
||||
</h2>
|
||||
|
||||
<div className="p-5">
|
||||
{uniqueBrands?.map((brand) => (
|
||||
<>
|
||||
<div className="flex items-enter mr-4 mb-5">
|
||||
<input
|
||||
type="radio"
|
||||
id={brand}
|
||||
name="brand"
|
||||
onChange={() => handleBrandClick(brand)}
|
||||
className="w-4 h-4 text-pink-400 bg-gray-100 border-gray-300 focus:ring-pink-500 dark:focus:ring-pink-600 dark:ring-offset-gray-800 focus:ring-2 dark:border-gray-600"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="pink-radio"
|
||||
className="ml-2 text-sm font-medium dark:text-gray-300"
|
||||
>
|
||||
{brand}
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="h4 text-center py-2 bg-gray-300 rounded-full mb-2">
|
||||
Filer by Price
|
||||
</h2>
|
||||
|
||||
<div className="p-5 w-[15rem]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Price"
|
||||
value={priceFilter}
|
||||
onChange={handlePriceChange}
|
||||
className="w-full px-3 py-2 placeholder-gray-400 border rounded-lg focus:outline-none focus:ring focus:border-pink-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-5 pt-0">
|
||||
<button
|
||||
className="w-full border my-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<h2 className="h4 text-center mb-2">{products?.length} Products</h2>
|
||||
<div className="flex flex-wrap">
|
||||
{products.length === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
products?.map((p) => (
|
||||
<div className="p-3" key={p._id}>
|
||||
<ProductCard p={p} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shop;
|
@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { toast } from "react-toastify";
|
||||
import Loader from "../../components/Loader";
|
||||
import { setCredientials } from "../../redux/features/auth/authSlice";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useProfileMutation } from "../../redux/api/usersApiSlice";
|
||||
|
||||
const Profile = () => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const { userInfo } = useSelector((state) => state.auth);
|
||||
|
||||
const [updateProfile, { isLoading: loadingUpdateProfile }] =
|
||||
useProfileMutation();
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(userInfo.username);
|
||||
setEmail(userInfo.email);
|
||||
}, [userInfo.username, userInfo.email, userInfo]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const submitHandler = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Password are not match! try Again");
|
||||
} else {
|
||||
try {
|
||||
const res = await updateProfile({
|
||||
_id: userInfo._id,
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
}).unwrap();
|
||||
dispatch(setCredientials({ ...res }));
|
||||
toast.success("Profile updated successfully");
|
||||
} catch (error) {
|
||||
toast.error(error?.data?.message || error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 mt-[10rem]">
|
||||
<div className="flex justify-center align-center md:flex md:space-x-4">
|
||||
<div className="md:w-1/3">
|
||||
<h2 className="text-2xl font-semibold mb-4">Update Profile</h2>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter name"
|
||||
className="form-input p-4 rounded-sm border border-slate-600 w-full"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Enter email"
|
||||
className="form-input p-4 rounded-sm border border-slate-600 w-full"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
className="form-input p-4 rounded-sm border border-slate-600 w-full"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Confirm Password"
|
||||
className="form-input p-4 rounded-sm border border-slate-600 w-full"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-pink-500 text-white py-2 px-4 rounder hover:bg-pink-600"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<Link
|
||||
to="/user-orders"
|
||||
className="bg-pink-600 text-white py-2 px-4 rounded hover:bg-pink-700"
|
||||
>
|
||||
My Orders
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loadingUpdateProfile && <Loader />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
@ -0,0 +1,77 @@
|
||||
import Message from "../../components/Message";
|
||||
import Loader from "../../components/Loader";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useGetMyOrdersQuery } from "../../redux/api/orderApiSlice";
|
||||
|
||||
const UserOrder = () => {
|
||||
const { data: orders, isLoading, error } = useGetMyOrdersQuery();
|
||||
console.log(orders)
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">My Orders</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<Message variant="danger">{error?.data?.error || error.error}</Message>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="py-2">IMAGE</td>
|
||||
<td className="py-2">ID</td>
|
||||
<td className="py-2">DATE</td>
|
||||
<td className="py-2">TOTAL</td>
|
||||
<td className="py-2">PAID</td>
|
||||
<td className="py-2">DELIVERED</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => (
|
||||
<tr key={order._id}>
|
||||
<img
|
||||
src={order.orderItems[0].image}
|
||||
alt={order.user}
|
||||
className="w-[6rem] mb-5"
|
||||
/>
|
||||
|
||||
<td className="py-2">{order._id}</td>
|
||||
<td className="py-2">{order.createdAt.substring(0, 10)}</td>
|
||||
<td className="py-2">$ {order.totalPrice}</td>
|
||||
<td className="py-2">{order.isPaid ? (
|
||||
<p className="p-1 text-center bg-green-400 w-[6rem] rounded-full">
|
||||
Completed
|
||||
</p>
|
||||
): (
|
||||
<p className="p-1 text-center bg-red-400 w-[6rem] rounded-full">
|
||||
Pending
|
||||
</p>
|
||||
)}</td>
|
||||
<td className="py-2 px-2">{order.isDelivered ? (
|
||||
<p className="p-1 text-center bg-green-400 w-[6rem] rounded-full">
|
||||
Completed
|
||||
</p>
|
||||
): (
|
||||
<p className="p-1 text-center bg-red-400 w-[6rem] rounded-full">
|
||||
Pending
|
||||
</p>
|
||||
)}</td>
|
||||
|
||||
<td className="px-2 py-2">
|
||||
<Link to={`/order/${order._id}`}>
|
||||
<button className="bg-pink-400 text-black py-2 px-3 rounded">
|
||||
View Details
|
||||
</button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserOrder;
|
@ -0,0 +1,19 @@
|
||||
// Importing necessary functions from the Redux Toolkit for API handling
|
||||
import { fetchBaseQuery, createApi } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
// Importing the base URL from constants for API requests
|
||||
import { BASE_URL } from "../constants";
|
||||
|
||||
// Creating a base query using the fetchBaseQuery function with the specified base URL
|
||||
const baseQuery = fetchBaseQuery({ baseUrl: BASE_URL });
|
||||
|
||||
// Creating an API slice using createApi with the configured base query and tag types
|
||||
export const apiSlice = createApi({
|
||||
baseQuery, // Configuring the API with the previously defined base query
|
||||
|
||||
// Defining tag types for better organization and documentation of entities (Product, Order, User, Category)
|
||||
tagTypes: ["Product", "Order", "User", "Category"],
|
||||
|
||||
// Defining endpoints (empty for now, to be extended as needed)
|
||||
endpoints: () => ({}),
|
||||
});
|
@ -0,0 +1,40 @@
|
||||
import { apiSlice } from "./apiSlice";
|
||||
import { CATEGORY_URL } from "../constants";
|
||||
|
||||
export const categoryApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
createCategory: builder.mutation({
|
||||
query: (newCategory) => ({
|
||||
url: `${CATEGORY_URL}`,
|
||||
method: 'POST',
|
||||
body: newCategory,
|
||||
})
|
||||
}),
|
||||
|
||||
updateCategory: builder.mutation({
|
||||
query: ({categoryId, updatedCategory}) => ({
|
||||
url: `${CATEGORY_URL}/${categoryId}`,
|
||||
method: 'PUT',
|
||||
body: updatedCategory,
|
||||
})
|
||||
}),
|
||||
|
||||
deleteCategory: builder.mutation({
|
||||
query: (categoryId) => ({
|
||||
url: `${CATEGORY_URL}/${categoryId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}),
|
||||
|
||||
fetchCategories: builder.query({
|
||||
query: () => `${CATEGORY_URL}/categories`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
export const {
|
||||
useCreateCategoryMutation,
|
||||
useUpdateCategoryMutation,
|
||||
useDeleteCategoryMutation,
|
||||
useFetchCategoriesQuery
|
||||
} = categoryApiSlice
|
@ -0,0 +1,80 @@
|
||||
import { apiSlice } from "./apiSlice";
|
||||
import { ORDERS_URL, PAYPAL_URL } from "../constants";
|
||||
|
||||
export const orderApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
createOrder: builder.mutation({
|
||||
query: (order) => ({
|
||||
url: ORDERS_URL,
|
||||
method: "POST",
|
||||
body: order,
|
||||
}),
|
||||
}),
|
||||
|
||||
getOrderDetails: builder.query({
|
||||
query: (id) => ({
|
||||
url: `${ORDERS_URL}/${id}`,
|
||||
}),
|
||||
}),
|
||||
|
||||
payOrder: builder.mutation({
|
||||
query: ({ orderId, details }) => ({
|
||||
url: `${ORDERS_URL}/${orderId}/pay`,
|
||||
method: "PUT",
|
||||
body: details,
|
||||
}),
|
||||
}),
|
||||
|
||||
getPaypalClientId: builder.query({
|
||||
query: () => ({
|
||||
url: PAYPAL_URL,
|
||||
}),
|
||||
}),
|
||||
|
||||
getMyOrders: builder.query({
|
||||
query: () => ({
|
||||
url: `${ORDERS_URL}/mine`,
|
||||
}),
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
getOrders: builder.query({
|
||||
query: () => ({
|
||||
url: ORDERS_URL,
|
||||
}),
|
||||
}),
|
||||
|
||||
deliverOrder: builder.mutation({
|
||||
query: (orderId) => ({
|
||||
url: `${ORDERS_URL}/${orderId}/deliver`,
|
||||
method: "PUT",
|
||||
}),
|
||||
}),
|
||||
|
||||
getTotalOrders: builder.query({
|
||||
query: () => `${ORDERS_URL}/total-orders`,
|
||||
}),
|
||||
|
||||
getTotalSales: builder.query({
|
||||
query: () => `${ORDERS_URL}/total-sales`,
|
||||
}),
|
||||
|
||||
getTotalSalesByDate: builder.query({
|
||||
query: () => `${ORDERS_URL}/total-sales-by-date`,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetTotalOrdersQuery,
|
||||
useGetTotalSalesQuery,
|
||||
useGetTotalSalesByDateQuery,
|
||||
// ------------------
|
||||
useCreateOrderMutation,
|
||||
useGetOrderDetailsQuery,
|
||||
usePayOrderMutation,
|
||||
useGetPaypalClientIdQuery,
|
||||
useGetMyOrdersQuery,
|
||||
useDeliverOrderMutation,
|
||||
useGetOrdersQuery,
|
||||
} = orderApiSlice;
|
@ -0,0 +1,107 @@
|
||||
import { PRODUCT_URL, UPLOAD_URL } from "../constants";
|
||||
import { apiSlice } from "./apiSlice";
|
||||
|
||||
export const productApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getProducts: builder.query({
|
||||
query: ({ keyword }) => ({
|
||||
url: `${PRODUCT_URL}`,
|
||||
params: { keyword },
|
||||
}),
|
||||
keepUnusedDataFor: 5,
|
||||
providesTags: ["Product"],
|
||||
}),
|
||||
|
||||
getProductById: builder.query({
|
||||
query: (productId) => `${PRODUCT_URL}/${productId}`,
|
||||
providesTags: (result, error, productId) => [
|
||||
{ types: "Product", id: productId },
|
||||
],
|
||||
}),
|
||||
|
||||
allProducts: builder.query({
|
||||
query: () => `${PRODUCT_URL}/fetchAllProducts`,
|
||||
}),
|
||||
|
||||
getProductDetails: builder.query({
|
||||
query: (productId) => ({
|
||||
url: `${PRODUCT_URL}/${productId}`,
|
||||
}),
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
createProduct: builder.mutation({
|
||||
query: (productData) => ({
|
||||
url: `${PRODUCT_URL}`,
|
||||
method: "POST",
|
||||
body: productData,
|
||||
}),
|
||||
invalidatesTags: ["Product"],
|
||||
}),
|
||||
|
||||
updateProduct: builder.mutation({
|
||||
query: ({ productId, formData }) => ({
|
||||
url: `${PRODUCT_URL}/${productId}`,
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
}),
|
||||
}),
|
||||
|
||||
uploadProductImage: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${UPLOAD_URL}`,
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
|
||||
deleteProduct: builder.mutation({
|
||||
query: (productId) => ({
|
||||
url: `${PRODUCT_URL}/${productId}`,
|
||||
method: "DELETE",
|
||||
}),
|
||||
providesTags: ["Product"],
|
||||
}),
|
||||
|
||||
createReview: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${PRODUCT_URL}/${data.productId}/reviews`,
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
|
||||
getTopProduct: builder.query({
|
||||
query: () => `${PRODUCT_URL}/top`,
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
getNewProduct: builder.query({
|
||||
query: () => `${PRODUCT_URL}/new`,
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
getFilteredProducts: builder.query({
|
||||
query: ({ checked, radio }) => ({
|
||||
url: `${PRODUCT_URL}/filtered-products`,
|
||||
method: "POST",
|
||||
body: { checked, radio },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetProductsQuery,
|
||||
useGetProductByIdQuery,
|
||||
useAllProductsQuery,
|
||||
useGetProductDetailsQuery,
|
||||
useCreateProductMutation,
|
||||
useUpdateProductMutation,
|
||||
useDeleteProductMutation,
|
||||
useCreateReviewMutation,
|
||||
useGetTopProductQuery,
|
||||
useGetNewProductQuery,
|
||||
useUploadProductImageMutation,
|
||||
useGetFilteredProductsQuery,
|
||||
} = productApiSlice;
|
@ -0,0 +1,86 @@
|
||||
// Importing the apiSlice to extend and create a user-specific API slice
|
||||
import { apiSlice } from "./apiSlice";
|
||||
|
||||
// Importing the constant for the users' URL from the constants file
|
||||
import { USERS_URL } from "../constants";
|
||||
|
||||
// Creating a user-specific API slice by injecting endpoints into the base apiSlice
|
||||
export const userApiSlice = apiSlice.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
// Defining a login endpoint as a mutation with specified query details
|
||||
login: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${USERS_URL}/login`, // Constructing the URL for the login endpoint
|
||||
method: "POST", // Setting the HTTP method to POST for login
|
||||
body: data, // Including the request data in the body
|
||||
}),
|
||||
}),
|
||||
|
||||
logout: builder.mutation({
|
||||
query: () => ({
|
||||
url: `${USERS_URL}/logout`,
|
||||
method: "POST",
|
||||
}),
|
||||
}),
|
||||
|
||||
register: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${USERS_URL}/register`,
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
|
||||
profile: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${USERS_URL}/profile`,
|
||||
method: "PUT",
|
||||
body: data,
|
||||
}),
|
||||
}),
|
||||
|
||||
getUsers: builder.query({
|
||||
query: () => ({
|
||||
url: `${USERS_URL}/getAllUsers`,
|
||||
}),
|
||||
providesTags: ["User"],
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
deleteUser: builder.mutation({
|
||||
query: (userId) => ({
|
||||
url: `${USERS_URL}/${userId}`,
|
||||
method: "DELETE",
|
||||
}),
|
||||
}),
|
||||
|
||||
getUserDetails: builder.query({
|
||||
query: (id) => ({
|
||||
url: `${USERS_URL}/${id}`,
|
||||
}),
|
||||
keepUnusedDataFor: 5,
|
||||
}),
|
||||
|
||||
updateUser: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: `${USERS_URL}/${data.userId}`,
|
||||
method: "PUT",
|
||||
body: data,
|
||||
}),
|
||||
invalidatesTags: ["User"],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Extracting the generated hook for the login mutation from the userApiSlice
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useLogoutMutation,
|
||||
useRegisterMutation,
|
||||
useProfileMutation,
|
||||
useGetUsersQuery,
|
||||
useDeleteUserMutation,
|
||||
useGetUserDetailsQuery,
|
||||
useUpdateUserMutation
|
||||
|
||||
} = userApiSlice;
|
@ -0,0 +1,7 @@
|
||||
export const BASE_URL = " "; //proxy
|
||||
export const USERS_URL = "/b6/users";
|
||||
export const CATEGORY_URL = '/b6/category'
|
||||
export const PRODUCT_URL = '/b6/products'
|
||||
export const UPLOAD_URL = '/b6/upload'
|
||||
export const ORDERS_URL = '/b6/orders'
|
||||
export const PAYPAL_URL = '/b6/config/paypal'
|
@ -0,0 +1,38 @@
|
||||
// Importing createSlice function from Redux Toolkit for creating a slice of the Redux store
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
// Initializing the initial state for the auth slice, retrieving user info from localStorage if available
|
||||
const initialState = {
|
||||
userInfo: localStorage.getItem("userInfo")
|
||||
? JSON.parse(localStorage.getItem("userInfo"))
|
||||
: null,
|
||||
};
|
||||
|
||||
// Creating the authSlice with a name, initial state, and reducer functions
|
||||
const authSlice = createSlice({
|
||||
name: "auth", // Name of the slice for identifying in the Redux store
|
||||
initialState, // Initial state of the auth slice
|
||||
|
||||
// Reducer functions to handle state changes
|
||||
reducers: {
|
||||
// Reducer for setting user credentials in the state and localStorage
|
||||
setCredientials: (state, action) => {
|
||||
state.userInfo = action.payload; // Updating user info in the state
|
||||
localStorage.setItem("userInfo", JSON.stringify(action.payload)); // Storing user info in localStorage
|
||||
const expirationTime = new Date().getTime() + 30 * 24 * 60 * 60 * 1000; // Setting expiration time (e.g., 30 days)
|
||||
localStorage.setItem("expirationTime", expirationTime); // Storing expiration time in localStorage
|
||||
},
|
||||
|
||||
// Reducer for logging out, clearing user info from the state and localStorage
|
||||
logout: (state) => {
|
||||
state.userInfo = null; // Clearing user info in the state
|
||||
localStorage.clear(); // Clearing all items in localStorage
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Extracting action creators for the setCredentials and logout reducers
|
||||
export const { setCredientials, logout } = authSlice.actions;
|
||||
|
||||
// Exporting the authSlice reducer to be used in the Redux store
|
||||
export default authSlice.reducer;
|
@ -0,0 +1,59 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { updateCart } from "../../../Utils/cartUtils.js";
|
||||
|
||||
const initialState = localStorage.getItem("cart")
|
||||
? JSON.parse(localStorage.getItem("cart"))
|
||||
: { cartItems: [], shippingAddress: {}, paymentMethod: "PayPal" };
|
||||
|
||||
const cartSlice = createSlice({
|
||||
name: "cart",
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart: (state, action) => {
|
||||
const { user, rating, numReviews, reviews, ...item } = action.payload;
|
||||
const existItem = state.cartItems.find((x) => x._id === item._id);
|
||||
|
||||
if (existItem) {
|
||||
state.cartItems = state.cartItems.map((x) =>
|
||||
x._id === existItem._id ? item : x
|
||||
);
|
||||
} else {
|
||||
state.cartItems = [...state.cartItems, item];
|
||||
}
|
||||
return updateCart(state, item);
|
||||
},
|
||||
|
||||
removeFromCart: (state, action) => {
|
||||
state.cartItems = state.cartItems.filter((x) => x._id !== action.payload);
|
||||
return updateCart(state);
|
||||
},
|
||||
|
||||
saveShippingAddress: (state, action) => {
|
||||
state.shippingAddress = action.payload;
|
||||
localStorage.setItem("cart", JSON.stringify(state));
|
||||
},
|
||||
|
||||
savePaymentMethod: (state, action) => {
|
||||
state.paymentMethod = action.payload;
|
||||
localStorage.setItem("cart", JSON.stringify(state));
|
||||
},
|
||||
|
||||
clearCartItems: (state, action) => {
|
||||
state.cartItems = [];
|
||||
localStorage.setItem("cart", JSON.stringify(state));
|
||||
},
|
||||
|
||||
resetCart: (state) => (state = initialState),
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
savePaymentMethod,
|
||||
saveShippingAddress,
|
||||
clearCartItems,
|
||||
resetCart,
|
||||
} = cartSlice.actions;
|
||||
|
||||
export default cartSlice.reducer;
|
@ -0,0 +1,28 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
const favoriteSlice = createSlice({
|
||||
name: 'favorites',
|
||||
initialState: [],
|
||||
reducers: {
|
||||
addToFavorites: ( state, action ) => {
|
||||
if(!state.some((product) => product._id === action.payload._id)){
|
||||
state.push(action.payload)
|
||||
}
|
||||
},
|
||||
removeFromFavorites: (state, action) => {
|
||||
return state.filter((product) => product._id !== action.payload._id)
|
||||
},
|
||||
setFavorites: (state, action) => {
|
||||
return action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
addToFavorites,
|
||||
removeFromFavorites,
|
||||
setFavorites,
|
||||
} = favoriteSlice.actions
|
||||
|
||||
export const selectFavoriteProduct = (state) => state.favorites
|
||||
export default favoriteSlice.reducer
|
@ -0,0 +1,42 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
const initialState = {
|
||||
categories: [],
|
||||
products: [],
|
||||
checked: [],
|
||||
radio: [],
|
||||
brandCheckboxes: {},
|
||||
checkedBrands: [],
|
||||
};
|
||||
|
||||
const shopSlice = createSlice({
|
||||
name: "shop",
|
||||
initialState,
|
||||
reducers: {
|
||||
setCategories: (state, action) => {
|
||||
state.categories = action.payload;
|
||||
},
|
||||
setProducts: (state, action) => {
|
||||
state.products = action.payload;
|
||||
},
|
||||
setChecked: (state, action) => {
|
||||
state.checked = action.payload;
|
||||
},
|
||||
setRadio: (state, action) => {
|
||||
state.radio = action.payload;
|
||||
},
|
||||
setSelectedBrand: (state, action) => {
|
||||
state.selectedBrand = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setCategories,
|
||||
setProducts,
|
||||
setChecked,
|
||||
setRadio,
|
||||
setSelectedBrand,
|
||||
} = shopSlice.actions;
|
||||
|
||||
export default shopSlice.reducer;
|
@ -0,0 +1,45 @@
|
||||
// Importing configureStore function from Redux Toolkit for creating a Redux store
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
|
||||
// Importing setupListeners function from Redux Toolkit for setting up API query listeners
|
||||
import { setupListeners } from "@reduxjs/toolkit/query/react";
|
||||
|
||||
// Importing the API slice created using createApi from the apiSlice file
|
||||
import { apiSlice } from "./api/apiSlice";
|
||||
|
||||
// Importing the authReducer from the authSlice file
|
||||
import authReducer from "./features/auth/authSlice";
|
||||
|
||||
import favoritesReducer from "../redux/features/favorites/favoriteSlice";
|
||||
import CartSliceReducer from "../redux/features/cart/cartSlice"
|
||||
import { getFavoritesFromLocalStorage } from "../Utils/localStorage";
|
||||
import shopReducer from "../redux/features/shop/shopSlice";
|
||||
const initialFavorites = getFavoritesFromLocalStorage() || [];
|
||||
|
||||
// Creating the Redux store with the configured reducers, middleware, and devTools settings
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
[apiSlice.reducerPath]: apiSlice.reducer, // Including the API slice reducer under a specific key
|
||||
auth: authReducer, // Including the authReducer for handling authentication state
|
||||
favorites: favoritesReducer,
|
||||
cart: CartSliceReducer,
|
||||
shop: shopReducer,
|
||||
},
|
||||
|
||||
preloadedState: {
|
||||
favorites: initialFavorites,
|
||||
},
|
||||
|
||||
// Configuring middleware to include the API middleware along with the default middleware
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(apiSlice.middleware),
|
||||
|
||||
// Enabling Redux DevTools extension for debugging
|
||||
devTools: true,
|
||||
});
|
||||
|
||||
// Setting up API query listeners with the Redux store's dispatch function
|
||||
setupListeners(store.dispatch);
|
||||
|
||||
// Exporting the configured Redux store for use in the application
|
||||
export default store;
|
@ -0,0 +1,13 @@
|
||||
import flowbitePlugin from 'flowbite/plugin'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [flowbitePlugin],
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/b6/": "http://localhost:4006",
|
||||
"/uploads/": "http://localhost:4006"
|
||||
}
|
||||
},
|
||||
})
|
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "capstone3fs",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"backend": "nodemon backend/index.js",
|
||||
"frontend": "npm run dev --prefix frontend",
|
||||
"dev": "concurrently \"npm run frontend\" \"npm run backend\" "
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"concurrently": "^8.2.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-handler": "^1.2.0",
|
||||
"express-formidable": "^1.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
After Width: | Height: | Size: 437 KiB |
After Width: | Height: | Size: 470 KiB |
After Width: | Height: | Size: 459 KiB |
After Width: | Height: | Size: 406 KiB |
After Width: | Height: | Size: 484 KiB |
After Width: | Height: | Size: 477 KiB |
After Width: | Height: | Size: 486 KiB |
After Width: | Height: | Size: 488 KiB |
After Width: | Height: | Size: 449 KiB |
After Width: | Height: | Size: 486 KiB |
After Width: | Height: | Size: 473 KiB |
After Width: | Height: | Size: 473 KiB |