diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index 3c00ae0..4543673 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -20,7 +20,7 @@ const upload = multer({ */ export const getProducts = async (req: AuthenticatedRequest, res: Response) => { try { - const products = await ProductModel.find(); + const products = await ProductModel.find().sort({"timeUpdated": -1}); res.status(200).json(products); } catch (error) { res.status(500).json({ message: "Error fetching products", error }); @@ -47,17 +47,37 @@ export const getProductById = async (req: AuthenticatedRequest, res: Response) = }; /* - * search for product by name + * search for product by name, tags, price */ -export const getProductsByName = async (req: AuthenticatedRequest, res: Response) => { +export const getProductsByQuery = async (req: AuthenticatedRequest, res: Response) => { try { - const query = req.params.query; - const products = await ProductModel.find({ name: { $regex: query, $options: "i" } }); + const keyword = req.query.keyword; + let tags: string[] = []; + if (typeof req.query.tags === "string" && req.query.tags.length > 0) { + tags = req.query.tags.split(","); + } + const price = req.query.price; + let sortField : string = String(req.query.order) ?? ""; + let sortOrder = 1 + if (sortField === 'timeUpdated' || sortField === 'priceDesc') sortOrder = -1 + if (sortField === 'priceAsc' || sortField === 'priceDesc') sortField = 'price' + + let query: any = {} + if (typeof keyword === "string" && keyword.length > 0){ + query.name = { $regex: keyword || "", $options: "i" } + } + if (tags.length > 0) { + query.tags = { $in: tags }; + } + if (price) query.price = {$lte: price}; + + const products = await ProductModel.find(query).sort({[sortField]: sortOrder === 1 ? 'asc' : 'desc'}); if (!products) { return res.status(404).json({ message: "Product not found" }); } res.status(200).json(products); } catch (error) { + console.error(error); res.status(500).json({ message: "Error getting product", error }); } }; @@ -69,7 +89,7 @@ export const addProduct = [ upload, async (req: AuthenticatedRequest, res: Response) => { try { - const { name, price, description } = req.body; + const { name, price, description, tags } = req.body; if (!req.user) return res.status(404).json({ message: "User not found" }); const userId = req.user._id; const userEmail = req.user.userEmail; @@ -100,6 +120,7 @@ export const addProduct = [ price, description, userEmail, + tags, images, timeCreated: new Date(), timeUpdated: new Date(), @@ -190,6 +211,7 @@ export const updateProductById = [ price: req.body.price, description: req.body.description, images: finalImages, + tags: req.body.tags ?? [], timeUpdated: new Date(), }, { new: true }, diff --git a/backend/src/models/product.ts b/backend/src/models/product.ts index ca7386a..885b5b9 100644 --- a/backend/src/models/product.ts +++ b/backend/src/models/product.ts @@ -24,6 +24,11 @@ const productSchema = new Schema({ type: String, required: true, }, + tags: { + type: [String], + enum: ['Electronics', 'School Supplies', 'Dorm Essentials', 'Furniture', 'Clothes', 'Miscellaneous'], + required: false + }, images: [{ type: String }], }); diff --git a/backend/src/routes/product.ts b/backend/src/routes/product.ts index 581fb50..fc92e53 100644 --- a/backend/src/routes/product.ts +++ b/backend/src/routes/product.ts @@ -5,14 +5,14 @@ import { addProduct, deleteProductById, updateProductById, - getProductsByName, + getProductsByQuery, } from "src/controllers/products"; import { authenticateUser } from "src/validators/authUserMiddleware"; const router = express.Router(); router.get("/", authenticateUser, getProducts); +router.get("/search", authenticateUser, getProductsByQuery); router.get("/:id", authenticateUser, getProductById); -router.get("/search/:query", authenticateUser, getProductsByName); router.post("/", authenticateUser, addProduct); router.delete("/:id", authenticateUser, deleteProductById); router.patch("/:id", authenticateUser, updateProductById); diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index aa40a0f..5536197 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,5 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { get } from "src/api/requests"; +import { FaFilter } from "react-icons/fa6"; +import { tags } from "../utils/constants.tsx"; +import { orderMethods } from "../utils/constants.tsx"; interface Props { setProducts: (products: []) => void; @@ -7,17 +10,36 @@ interface Props { } export default function SearchBar({ setProducts, setError }: Props) { + const [dropdownHidden, setDropdownHidden] = useState(true); const [query, setQuery] = useState(null); + const [tagFilters, setTagFilters] = useState([]); + const [priceMax, setPriceMax] = useState(); + const [orderBy, setOrderBy] = useState("Most Recent"); useEffect(() => { /* - * if query is null, get all products - * otherwise get products that match the query + * if query and tags and price are null, get all products + * otherwise get products that match the query/tags/price */ const search = async () => { try { - if (query && query.trim().length > 0) { - await get(`/api/products/search/${query}`).then((res) => { + if ( + (query && query.trim().length > 0) || + tagFilters.length > 0 || + priceMax || + orderBy !== "Most Recent" + ) { + const selectedTags = tagFilters.length > 0 ? tagFilters.join(",") : ""; + let keyword = ""; + if (query) { + keyword = query.trim().length > 0 ? query.trim() : ""; + } + let price = ""; + if (priceMax) price = String(priceMax); + + await get( + `/api/products/search?keyword=${keyword}&tags=${selectedTags}&price=${price}&order=${orderMethods[orderBy]}`, + ).then((res) => { if (res.ok) { res.json().then((data) => { setProducts(data); @@ -39,14 +61,129 @@ export default function SearchBar({ setProducts, setError }: Props) { } }; search(); - }, [query]); + }, [query, tagFilters, priceMax, orderBy]); + + // handle dropdown display + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setDropdownHidden(true); + } + }; + document.addEventListener("mousedown", handleClickOutside); + }, []); return ( - setQuery(e.target.value)} - placeholder="Search for a product..." - className="w-full bg-[#F8F8F8] shadow-md p-3 px-6 mx-auto my-2 rounded-3xl" - /> + <> +
+ setQuery(e.target.value)} + placeholder="Search for a product..." + className="w-full bg-[#F8F8F8] shadow-md p-3 pr-12 pl-6 rounded-3xl" + /> +
+ { + setDropdownHidden((prev) => !prev); + }} + className="absolute right-6 top-1/2 transform -translate-y-1/2 text-[#00629B] text-[1.2rem] cursor-pointer" + /> +
+ + + {/* Product Tags */} +
+ +
{ + if (dropdownRef.current) dropdownRef.current.hidden = false; + }} + ref={buttonRef} + > + {productTags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ {/* Product Tags Dropdown */} + {productTags.length !== tags.length && ( +
+
+ {tags.map((tag) => { + if (!productTags.includes(tag)) { + return ( +

{ + setProductTags([...productTags, tag]); + }} + key={tag} + className="px-4 py-2 text-sm text-gray-700 hover:cursor-pointer" + > + {tag} +

+ ); + } + })} +
+
+ )} +
+ {/* Images */}
+ {/* Product Tags */} +
+ +
{ + if (dropdownRef.current) dropdownRef.current.hidden = false; + }} + ref={buttonRef} + > + {productTags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ {/* Product Tags Dropdown */} + {productTags.length !== tags.length && ( +
+
+ {tags.map((tag) => { + if (!productTags.includes(tag)) { + return ( +

{ + setProductTags([...productTags, tag]); + }} + key={tag} + className="px-4 py-2 text-sm text-gray-700 hover:cursor-pointer" + > + {tag} +

+ ); + } + })} +
+
+ )} +
+
)} + {product?.tags && ( +
+ {product.tags.map((tag) => ( +
+ {tag} +
+ ))} +
+ )} {!hasPermissions && (
setIsHovered(true)} diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx new file mode 100644 index 0000000..0105f12 --- /dev/null +++ b/frontend/src/utils/constants.tsx @@ -0,0 +1,15 @@ +export const tags = [ + "Electronics", + "School Supplies", + "Dorm Essentials", + "Furniture", + "Clothes", + "Miscellaneous", +]; + +export const orderMethods = { + "Most Recent": "timeUpdated", + "Price (low-high)": "priceAsc", + "Price (high-low)": "priceDesc", + "Alphabetical (A-Z)": "name", +} as { [key: string]: string };