From d937c187f2d2d89e115163c027833ec28ab409af Mon Sep 17 00:00:00 2001 From: Taha Qamar Date: Tue, 27 Jan 2026 22:05:56 -0800 Subject: [PATCH 1/2] finishes search feature with multiple filters and sorting --- backend/src/controllers/products.ts | 108 ++++++++-- backend/src/models/product.ts | 11 + backend/src/routes/product.ts | 2 +- frontend/src/components/FilterSort.tsx | 111 ++++++++++ frontend/src/components/SearchBar.tsx | 42 +--- frontend/src/pages/AddProduct.tsx | 169 ++++++++++++--- frontend/src/pages/EditProduct.tsx | 203 ++++++++++++++---- .../src/pages/Individual-product-page.tsx | 13 ++ frontend/src/pages/Marketplace.tsx | 69 +++++- 9 files changed, 604 insertions(+), 124 deletions(-) create mode 100644 frontend/src/components/FilterSort.tsx diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index 3c00ae0..a5da31d 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -16,11 +16,76 @@ const upload = multer({ }).array("images", 10); /** - * get all the products in database + * get all the products in database (keep filters, sorting in mind) */ export const getProducts = async (req: AuthenticatedRequest, res: Response) => { try { - const products = await ProductModel.find(); + const { sortBy, order, minPrice, maxPrice, condition, tags } = req.query; + + // object containing different filters we can apply + const filters: any = {}; + + // Check for filters and add them to object + if(minPrice || maxPrice) { + filters.price = {}; + if(minPrice) filters.price.$gte = Number(minPrice); + if(maxPrice) filters.price.$lte = Number(maxPrice); + } + + // Filter by specific condition + if(condition) { + filters.condition = condition; + } + + // Filter by tags + if(tags) { + // Handle both single tag and multiple tags + let tagArray: string[]; + + if (Array.isArray(tags)) { + + // Already an array: ?tags=Electronics&tags=Furniture + tagArray = tags as string[]; + } else if (typeof tags === 'string') { + + // Single string, could be comma-separated: ?tags=Electronics,Furniture + tagArray = tags.includes(',') ? tags.split(',').map(t => t.trim()) : [tags]; + + } else { + tagArray = []; + } + + if (tagArray.length > 0) { + filters.tags = { $in: tagArray }; + } + } + + // sort object for different sorting options + const sortTypes: any = {} + + if(sortBy) { + const sortOrder = order === "asc" ? 1 : -1; + + switch(sortBy) { + case "price": + sortTypes.price = sortOrder; + break; + case "timeCreated": + sortTypes.timeCreated = sortOrder; + break; + case "condition": + sortTypes.condition = sortOrder; + break; + default: + // newest is default + sortTypes.timeCreated = -1; + } + } else { + // default sorting by newest + sortTypes.timeCreated = -1; + } + + const products = await ProductModel.find(filters).sort(sortTypes); res.status(200).json(products); } catch (error) { res.status(500).json({ message: "Error fetching products", error }); @@ -63,19 +128,21 @@ export const getProductsByName = async (req: AuthenticatedRequest, res: Response }; /** - * add product to database thru name, price, description, and userEmail + * add product to database thru name, price, description, userEmail, and condition */ export const addProduct = [ upload, async (req: AuthenticatedRequest, res: Response) => { try { - const { name, price, description } = req.body; + const { name, price, description, category, condition } = req.body; if (!req.user) return res.status(404).json({ message: "User not found" }); const userId = req.user._id; const userEmail = req.user.userEmail; - if (!name || !price || !userEmail) { - return res.status(400).json({ message: "Name, price, and userEmail are required." }); + if (!name || !price || !userEmail || !condition) { + return res.status(400).json({ message: "Name, price, userEmail, and condition are required." }); } + + const tags = category ? [category] : []; const images: string[] = []; if (req.files && Array.isArray(req.files)) { @@ -101,6 +168,8 @@ export const addProduct = [ description, userEmail, images, + condition, + tags, timeCreated: new Date(), timeUpdated: new Date(), }); @@ -168,6 +237,12 @@ export const updateProductById = [ return res.status(400).json({ message: "User does not own this product" }); } + // handle tags input + let tags: string[] | undefined; + if (req.body.category) { + tags = [req.body.category]; + } + let existing = req.body.existingImages || []; if (!Array.isArray(existing)) existing = [existing]; @@ -183,15 +258,22 @@ export const updateProductById = [ const finalImages = [...existing, ...newUrls]; + const updateData: any = { + name: req.body.name, + price: req.body.price, + description: req.body.description, + condition: req.body.condition, + images: finalImages, + timeUpdated: new Date(), + }; + + if (tags) { + updateData.tags = tags; + } + const updatedProduct = await ProductModel.findByIdAndUpdate( id, - { - name: req.body.name, - price: req.body.price, - description: req.body.description, - images: finalImages, - timeUpdated: new Date(), - }, + updateData, { new: true }, ); diff --git a/backend/src/models/product.ts b/backend/src/models/product.ts index ca7386a..7ce1398 100644 --- a/backend/src/models/product.ts +++ b/backend/src/models/product.ts @@ -11,6 +11,7 @@ const productSchema = new Schema({ }, description: { type: String, + required: false, }, timeCreated: { type: Date, @@ -24,6 +25,16 @@ const productSchema = new Schema({ type: String, required: true, }, + tags: { + type: [String], + enum: ['Electronics', 'School Supplies', 'Dorm Essentials', 'Furniture', 'Clothes', 'Miscellaneous'], + required: false + }, + condition: { + type: String, + enum: ["New", "Like New", "Used", "For Parts"], + required: true, + }, images: [{ type: String }], }); diff --git a/backend/src/routes/product.ts b/backend/src/routes/product.ts index 581fb50..3945a82 100644 --- a/backend/src/routes/product.ts +++ b/backend/src/routes/product.ts @@ -11,8 +11,8 @@ import { authenticateUser } from "src/validators/authUserMiddleware"; const router = express.Router(); router.get("/", authenticateUser, getProducts); -router.get("/:id", authenticateUser, getProductById); router.get("/search/:query", authenticateUser, getProductsByName); +router.get("/:id", authenticateUser, getProductById); router.post("/", authenticateUser, addProduct); router.delete("/:id", authenticateUser, deleteProductById); router.patch("/:id", authenticateUser, updateProductById); diff --git a/frontend/src/components/FilterSort.tsx b/frontend/src/components/FilterSort.tsx new file mode 100644 index 0000000..0cbad05 --- /dev/null +++ b/frontend/src/components/FilterSort.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +interface FilterBarProps { + filters: any; + setFilters: (filters: any) => void; +} + +const TAGS = [ + 'Electronics', + 'School Supplies', + 'Dorm Essentials', + 'Furniture', + 'Clothes', + 'Miscellaneous' +]; + +export default function FilterBar({ filters, setFilters }: FilterBarProps) { + const handleTagToggle = (tag: string) => { + const currentTags = filters.tags || []; + const newTags = currentTags.includes(tag) + ? currentTags.filter((t: string) => t !== tag) + : [...currentTags, tag]; + + setFilters({ ...filters, tags: newTags }); + }; + + const clearFilters = () => { + setFilters({ + sortBy: 'timeCreated', + order: 'desc' + }); + }; + + return ( +
+ {/* Price Range */} +
+

Price Range

+ setFilters({ ...filters, minPrice: e.target.value ? Number(e.target.value) : undefined })} + /> + setFilters({ ...filters, maxPrice: e.target.value ? Number(e.target.value) : undefined })} + /> +
+ + {/* Condition */} +
+

Condition

+ +
+ + {/* Tags/Categories */} +
+

Categories

+
+ {TAGS.map(tag => ( + + ))} +
+
+ + {/* Sorting */} +
+

Sort By

+ + + +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index aa40a0f..80d2ee6 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,50 +1,22 @@ -import { useEffect, useState } from "react"; -import { get } from "src/api/requests"; +import { useState } from "react"; interface Props { - setProducts: (products: []) => void; + setProducts: (query: string) => void; setError: (error: string) => void; } export default function SearchBar({ setProducts, setError }: Props) { const [query, setQuery] = useState(null); - useEffect(() => { - /* - * if query is null, get all products - * otherwise get products that match the query - */ - const search = async () => { - try { - if (query && query.trim().length > 0) { - await get(`/api/products/search/${query}`).then((res) => { - if (res.ok) { - res.json().then((data) => { - setProducts(data); - }); - } - }); - } else { - await get(`/api/products/`).then((res) => { - if (res.ok) { - res.json().then((data) => { - setProducts(data); - }); - } - }); - } - } catch (err) { - setError("Unable to display products. Try again later."); - console.error(err); - } - }; - search(); - }, [query]); + const handleChange = (value: string) => { + setQuery(value); + setProducts(value); + }; return ( setQuery(e.target.value)} + onChange={(e) => handleChange(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" /> diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index bf68a9b..87cdc66 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -10,8 +10,26 @@ export function AddProduct() { const productName = useRef(null); const productPrice = useRef(null); const productDescription = useRef(null); + const productYear = useRef(null); + const productCategory = useRef(null); + const productCondition = useRef(null); const productImages = useRef(null); + + const currentYear = new Date().getFullYear(); + const years = Array.from({ length: currentYear - 1950 }, (_, i) => currentYear - i); + + + const categories = [ + 'Electronics', + 'School Supplies', + 'Dorm Essentials', + 'Furniture', + 'Clothes', + 'Miscellaneous']; + + const conditions = ["New", "Used"]; + const { user } = useContext(FirebaseContext); const [error, setError] = useState(false); const [fileError, setFileError] = useState(null); @@ -56,7 +74,7 @@ export function AddProduct() { setIsSubmitting(true); e.preventDefault(); try { - if (productName.current && productPrice.current && productDescription.current && user) { + if (productName.current && productPrice.current && productDescription.current && productYear.current && productCategory.current && productCondition.current && user) { let images; if (productImages.current && productImages.current.files) { images = productImages.current.files[0]; @@ -66,6 +84,9 @@ export function AddProduct() { body.append("name", productName.current.value); body.append("price", productPrice.current.value); body.append("description", productDescription.current.value); + body.append("year", productYear.current.value); + body.append("category", productCategory.current.value); + body.append("condition", productCondition.current.value); if (user.email) body.append("userEmail", user.email); if (productImages.current && productImages.current.files) { @@ -96,6 +117,66 @@ export function AddProduct() {

Add Product

+ {/* Images */} +
+ +

Upload up to 10 photos

+ + {newPreviews.length > 0 && ( +
+
+ {newPreviews.map((src, idx) => ( +
+ + +
+ ))} +
+
+ )} + + +
{/* Name */}
- {/* Images */} + {/* Year */}
-
- {newPreviews.length > 0 && ( -
-
- {newPreviews.map((src, idx) => ( -
- - -
- ))} -
-
- )} + {/* Category */} +
+ + +
- + + +
+ {/* Images */} +
+ +

Upload up to 10 photos

+ + {(newPreviews.length > 0 || existingImages.length > 0) && ( +
+ {existingImages.map((url) => ( +
+ + +
+ ))} + + {newPreviews.map((src, idx) => ( +
+ + +
+ ))} +
+ )} + + +
+
-
- {(newPreviews.length > 0 || existingImages.length > 0) && ( -
- {existingImages.map((url) => ( -
- - -
- ))} - - {newPreviews.map((src, idx) => ( -
- - -
- ))} -
- )} +
+ + +
- + +
@@ -257,4 +376,4 @@ export function EditProduct() { ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Individual-product-page.tsx b/frontend/src/pages/Individual-product-page.tsx index a54eb83..a36e59d 100644 --- a/frontend/src/pages/Individual-product-page.tsx +++ b/frontend/src/pages/Individual-product-page.tsx @@ -20,6 +20,7 @@ export function IndividualProductPage() { images: string[]; userEmail: string; description: string; + tags: string[]; }>(); const [error, setError] = useState(); const [hasPermissions, setHasPermissions] = useState(false); @@ -244,6 +245,18 @@ export function IndividualProductPage() {

)} + {product?.tags && ( +
+ {product.tags.map((tag) => ( +
+ {tag} +
+ ))} +
+ )} {!hasPermissions && (
setIsHovered(true)} diff --git a/frontend/src/pages/Marketplace.tsx b/frontend/src/pages/Marketplace.tsx index a80effd..e1a9fee 100644 --- a/frontend/src/pages/Marketplace.tsx +++ b/frontend/src/pages/Marketplace.tsx @@ -2,9 +2,19 @@ import { useState, useEffect, useContext } from "react"; import { Helmet } from "react-helmet-async"; import Product from "src/components/Product"; import SearchBar from "src/components/SearchBar"; +import FilterSort from "src/components/FilterSort"; import { FirebaseContext } from "src/utils/FirebaseProvider"; import { get, post } from "src/api/requests"; +interface FilterState { + minPrice?: number; + maxPrice?: number; + condition?: "New" | "Used" | ""; + tags?: string[]; + sortBy?: "price" | "timeCreated"; + order?: "asc" | "desc"; +} + export function Marketplace() { const [products, setProducts] = useState< Array<{ @@ -15,6 +25,13 @@ export function Marketplace() { }> >([]); const [error, setError] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [filters, setFilters] = useState({ + sortBy: "timeCreated", + order: "desc", + tags: [], + }); + const { user } = useContext(FirebaseContext); const [savedProducts, setSavedProducts] = useState([]); @@ -44,6 +61,53 @@ export function Marketplace() { } }; + const fetchProducts = async () => { + try { + setError(""); + + // If there's a search query, use search endpoint + if (searchQuery && searchQuery.trim().length > 0) { + const res = await get(`/api/products/search/${searchQuery}`); + if (res.ok) { + const data = await res.json(); + setProducts(data); + } else { + setError("Unable to search products. Try again later."); + } + return; + } + + // Otherwise, use filter/sort endpoint + const params = new URLSearchParams(); + + if (filters.minPrice) params.append("minPrice", filters.minPrice.toString()); + if (filters.maxPrice) params.append("maxPrice", filters.maxPrice.toString()); + if (filters.condition) params.append("condition", filters.condition); + if (filters.tags && filters.tags.length > 0) { + filters.tags.forEach((tag) => params.append("tags", tag)); + } + if (filters.sortBy) params.append("sortBy", filters.sortBy); + if (filters.order) params.append("order", filters.order); + + const queryString = params.toString(); + const res = await get(`/api/products${queryString ? `?${queryString}` : ""}`); + + if (res.ok) { + const data = await res.json(); + setProducts(data); + } else { + setError("Unable to display products. Try again later."); + } + } catch (err) { + setError("Unable to display products. Try again later."); + console.error(err); + } + }; + + useEffect(() => { + fetchProducts(); + }, [filters, searchQuery]); + useEffect(() => { fetchSavedProducts(); }, [user]); @@ -64,7 +128,10 @@ export function Marketplace() { Add Product
- + + + + {error &&

{error}

} {!error && products?.length === 0 && (

No products available

From 955dbc1ba04f4d7a451afd0ec21522fc0206c75b Mon Sep 17 00:00:00 2001 From: Taha Qamar Date: Wed, 4 Feb 2026 18:22:58 -0800 Subject: [PATCH 2/2] fixes bug with searching and filtering at the same time --- backend/src/controllers/products.ts | 60 ++++++++++++++++++++++++++++- frontend/.env.development | 3 -- frontend/src/pages/Marketplace.tsx | 29 +++++++------- 3 files changed, 74 insertions(+), 18 deletions(-) delete mode 100644 frontend/.env.development diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index a5da31d..d06153c 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -37,7 +37,7 @@ export const getProducts = async (req: AuthenticatedRequest, res: Response) => { filters.condition = condition; } - // Filter by tags + // Filter by category if(tags) { // Handle both single tag and multiple tags let tagArray: string[]; @@ -117,7 +117,63 @@ export const getProductById = async (req: AuthenticatedRequest, res: Response) = export const getProductsByName = async (req: AuthenticatedRequest, res: Response) => { try { const query = req.params.query; - const products = await ProductModel.find({ name: { $regex: query, $options: "i" } }); + const { sortBy, order, minPrice, maxPrice, condition, tags } = req.query; + + // Name is now a filter we can apply + const filters: any = { + name: { $regex: query, $options: "i" } + }; + + // price range + if (minPrice || maxPrice) { + filters.price = {}; + if (minPrice) filters.price.$gte = Number(minPrice); + if (maxPrice) filters.price.$lte = Number(maxPrice); + } + + // condition + if (condition) { + filters.condition = condition; + } + + // filter by category + if (tags) { + let tagArray: string[]; + + if (Array.isArray(tags)) { + tagArray = tags as string[]; + } else if (typeof tags === 'string') { + tagArray = tags.includes(',') ? tags.split(',').map(t => t.trim()) : [tags]; + } else { + tagArray = []; + } + + if (tagArray.length > 0) { + filters.tags = { $in: tagArray }; + } + } + + // Creates sorting options + const sortOptions: any = {}; + if (sortBy) { + const sortOrder = order === "asc" ? 1 : -1; + + switch (sortBy) { + case "price": + sortOptions.price = sortOrder; + break; + case "timeCreated": + sortOptions.timeCreated = sortOrder; + break; + default: + sortOptions.timeCreated = -1; + } + } else { + sortOptions.timeCreated = -1; + } + + const products = await ProductModel.find(filters).sort(sortOptions); + if (!products) { return res.status(404).json({ message: "Product not found" }); } diff --git a/frontend/.env.development b/frontend/.env.development deleted file mode 100644 index 3cd2602..0000000 --- a/frontend/.env.development +++ /dev/null @@ -1,3 +0,0 @@ -# Don't stop the React webpack build if there are lint errors. -ESLINT_NO_DEV_ERRORS=true -VITE_API_BASE_URL=http://localhost:5000 diff --git a/frontend/src/pages/Marketplace.tsx b/frontend/src/pages/Marketplace.tsx index e1a9fee..85f8333 100644 --- a/frontend/src/pages/Marketplace.tsx +++ b/frontend/src/pages/Marketplace.tsx @@ -65,19 +65,7 @@ export function Marketplace() { try { setError(""); - // If there's a search query, use search endpoint - if (searchQuery && searchQuery.trim().length > 0) { - const res = await get(`/api/products/search/${searchQuery}`); - if (res.ok) { - const data = await res.json(); - setProducts(data); - } else { - setError("Unable to search products. Try again later."); - } - return; - } - - // Otherwise, use filter/sort endpoint + // Search a part of filter/sort endpoint const params = new URLSearchParams(); if (filters.minPrice) params.append("minPrice", filters.minPrice.toString()); @@ -90,6 +78,21 @@ export function Marketplace() { if (filters.order) params.append("order", filters.order); const queryString = params.toString(); + + // Use in case of search with filters + if (searchQuery && searchQuery.trim().length > 0) { + const res = await get( + `/api/products/search/${searchQuery}${queryString ? `?${queryString}` : ""}` + ); + if (res.ok) { + const data = await res.json(); + setProducts(data); + } else { + setError("Unable to search products. Try again later."); + } + return; + } + const res = await get(`/api/products${queryString ? `?${queryString}` : ""}`); if (res.ok) {