From 61087da2d30220b4a4e2be45be52b6c595d304b6 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 21 Apr 2025 20:16:31 -0700 Subject: [PATCH 01/13] add tag attribute --- backend/src/controllers/products.ts | 3 ++- backend/src/models/product.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index 16723d9..00304ac 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -65,7 +65,7 @@ export const addProduct = [ upload.single("image"), async (req: AuthenticatedRequest, res: Response) => { try { - const { name, price, description } = req.body; + const { name, price, description, tags } = req.body; const userId = req.user.id; const userEmail = req.user.userEmail; if (!name || !price || !userEmail) { @@ -92,6 +92,7 @@ export const addProduct = [ description, userEmail, image, + tags, timeCreated: new Date(), timeUpdated: new Date(), }); diff --git a/backend/src/models/product.ts b/backend/src/models/product.ts index a8cc9a9..c96c795 100644 --- a/backend/src/models/product.ts +++ b/backend/src/models/product.ts @@ -25,6 +25,11 @@ const productSchema = new Schema({ required: true, }, image: { type: String }, + tags: { + type: String, + enum: ['Electronics', 'School Supplies', 'Dorm Essentials', 'Furniture', 'Clothes', 'Miscellaneous'], + required: false + } }); type Product = InferSchemaType; From 8335db1c0301863b358863bd46b7b549262c52de Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 21 Apr 2025 21:09:49 -0700 Subject: [PATCH 02/13] select tags in add product form --- backend/src/models/product.ts | 2 +- frontend/src/pages/AddProduct.tsx | 61 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/backend/src/models/product.ts b/backend/src/models/product.ts index c96c795..4fe72b8 100644 --- a/backend/src/models/product.ts +++ b/backend/src/models/product.ts @@ -26,7 +26,7 @@ const productSchema = new Schema({ }, image: { type: String }, tags: { - type: String, + type: [String], enum: ['Electronics', 'School Supplies', 'Dorm Essentials', 'Furniture', 'Clothes', 'Miscellaneous'], required: false } diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index 8f34157..9b27857 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -3,11 +3,21 @@ import { Helmet } from "react-helmet-async"; import { post } from "src/api/requests"; import { FirebaseContext } from "src/utils/FirebaseProvider"; +const tags = [ + "Electronics", + "School Supplies", + "Dorm Essentials", + "Furniture", + "Clothes", + "Miscellaneous", +]; + export function AddProduct() { const productName = useRef(null); const productPrice = useRef(null); const productDescription = useRef(null); const productImages = useRef(null); + const [productTags, setProductTags] = useState>([]); const [error, setError] = useState(false); const { user } = useContext(FirebaseContext); @@ -24,6 +34,9 @@ export function AddProduct() { body.append("name", productName.current.value); body.append("price", productPrice.current.value); body.append("description", productDescription.current.value); + productTags.forEach((tag) => { + body.append("tags[]", tag); + }); if (user.email) body.append("userEmail", user.email); if (image) body.append("image", image); @@ -109,6 +122,54 @@ export function AddProduct() { className="border border-gray-300 text-black text-sm rounded-md w-full p-2.5 y-600" /> + {/* Product Tags */} +
+ +
+ {productTags.map((tag) => ( +
+ {tag} + +
+ ))} +
+ {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} +

+ ); + } + })} +
+
+ )} +
{/* error message */} {error &&

Error adding product. Try again.

} From 2f7eab82731d3f40aa0e8cd57b84b1d90d773abe Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Mon, 21 Apr 2025 21:40:22 -0700 Subject: [PATCH 03/13] show and hide tags dropdown --- frontend/src/pages/AddProduct.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index 9b27857..9654410 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useRef, useContext, useState } from "react"; +import { FormEvent, useRef, useContext, useState, useEffect } from "react"; import { Helmet } from "react-helmet-async"; import { post } from "src/api/requests"; import { FirebaseContext } from "src/utils/FirebaseProvider"; @@ -21,6 +21,23 @@ export function AddProduct() { const [error, setError] = useState(false); const { user } = useContext(FirebaseContext); + // 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) + ) { + dropdownRef.current.hidden = true; + } + }; + document.addEventListener("mousedown", handleClickOutside); + }, []); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); try { @@ -130,6 +147,10 @@ export function AddProduct() {
{ + if (dropdownRef.current) dropdownRef.current.hidden = false; + }} + ref={buttonRef} > {productTags.map((tag) => (
))}
+ {/* Product Tags Dropdown */} {productTags.length !== tags.length && ( -
+
{tags.map((tag) => { if (!productTags.includes(tag)) { From 94921a33d25826ef0a450594016b1f102b0282f7 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Tue, 22 Apr 2025 11:47:45 -0700 Subject: [PATCH 04/13] create dropdown for search bar --- frontend/src/components/SearchBar.tsx | 60 +++++++++++++++++++++++---- frontend/src/pages/AddProduct.tsx | 10 +---- frontend/src/utils/constants.tsx | 8 ++++ 3 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 frontend/src/utils/constants.tsx diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index aa40a0f..a828aef 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,5 +1,7 @@ -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"; interface Props { setProducts: (products: []) => void; @@ -41,12 +43,56 @@ export default function SearchBar({ setProducts, setError }: Props) { search(); }, [query]); + // 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) + ) { + dropdownRef.current.hidden = 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" + /> +
+ { + if (dropdownRef.current) dropdownRef.current.hidden = !dropdownRef.current?.hidden; + }} + className="absolute right-6 top-1/2 transform -translate-y-1/2 text-[#00629B] text-[1.2rem] cursor-pointer" + /> +
+
+
+
+ Category + {tags.map((tag, index) => ( +
+ + +
+
+ ))} +
+
+ ); } diff --git a/frontend/src/pages/AddProduct.tsx b/frontend/src/pages/AddProduct.tsx index 9654410..76187b1 100644 --- a/frontend/src/pages/AddProduct.tsx +++ b/frontend/src/pages/AddProduct.tsx @@ -2,15 +2,7 @@ import { FormEvent, useRef, useContext, useState, useEffect } from "react"; import { Helmet } from "react-helmet-async"; import { post } from "src/api/requests"; import { FirebaseContext } from "src/utils/FirebaseProvider"; - -const tags = [ - "Electronics", - "School Supplies", - "Dorm Essentials", - "Furniture", - "Clothes", - "Miscellaneous", -]; +import { tags } from "../utils/constants.tsx"; export function AddProduct() { const productName = useRef(null); diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx new file mode 100644 index 0000000..d1185ee --- /dev/null +++ b/frontend/src/utils/constants.tsx @@ -0,0 +1,8 @@ +export const tags = [ + "Electronics", + "School Supplies", + "Dorm Essentials", + "Furniture", + "Clothes", + "Miscellaneous", +]; From a456c7688c1d4fd5528b00dae56d63e69883c930 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 23 Apr 2025 17:47:32 -0700 Subject: [PATCH 05/13] query by keyword and tags --- backend/src/controllers/products.ts | 20 ++++++++++++++--- backend/src/routes/product.ts | 4 ++-- frontend/src/components/SearchBar.tsx | 31 +++++++++++++++++++++------ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index c12fad4..9077bc6 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -45,15 +45,29 @@ export const getProductById = async (req: AuthenticatedRequest, res: Response) = /* * search for product by name */ -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(","); + } + + let query: any = {} + if (typeof keyword === "string" && keyword.length > 0){ + query.name = { $regex: keyword || "", $options: "i" } + } + if (tags.length > 0) { + query.tags = { $in: tags }; + } + + const products = await ProductModel.find(query); 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 }); } }; 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 a828aef..dccf0d9 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -10,16 +10,23 @@ interface Props { export default function SearchBar({ setProducts, setError }: Props) { const [query, setQuery] = useState(null); + const [tagFilters, setTagFilters] = useState([]); useEffect(() => { /* - * if query is null, get all products - * otherwise get products that match the query + * if query and tags are null, get all products + * otherwise get products that match the query/tags */ 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) { + const selectedTags = tagFilters.length > 0 ? tagFilters.join(",") : ""; + let keyword = ""; + if (query) { + keyword = query.trim().length > 0 ? query.trim() : ""; + } + + await get(`/api/products/search?keyword=${keyword}&tags=${selectedTags}`).then((res) => { if (res.ok) { res.json().then((data) => { setProducts(data); @@ -41,7 +48,7 @@ export default function SearchBar({ setProducts, setError }: Props) { } }; search(); - }, [query]); + }, [query, tagFilters]); // handle dropdown display const dropdownRef = useRef(null); @@ -86,7 +93,19 @@ export default function SearchBar({ setProducts, setError }: Props) { Category {tags.map((tag, index) => (
- + { + if (e.target.checked) { + setTagFilters([...tagFilters, tag]); + } else { + setTagFilters(tagFilters.filter((t) => t !== tag)); + } + }} + />
From 3d43200aba130f559445e6de3e1cc933c66aa157 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 23 Apr 2025 17:54:31 -0700 Subject: [PATCH 06/13] display tags on individual product page --- frontend/src/pages/Individual-product-page.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/pages/Individual-product-page.tsx b/frontend/src/pages/Individual-product-page.tsx index c4294c1..d71350d 100644 --- a/frontend/src/pages/Individual-product-page.tsx +++ b/frontend/src/pages/Individual-product-page.tsx @@ -16,6 +16,7 @@ export function IndividualProductPage() { image: string; userEmail: string; description: string; + tags: string[]; }>(); const [error, setError] = useState(); const [hasPermissions, setHasPermissions] = useState(false); @@ -100,6 +101,18 @@ export function IndividualProductPage() {

)} + {product?.tags && ( +
+ {product.tags.map((tag) => ( +
+ {tag} +
+ ))} +
+ )}

Interested? Contact them here: From dd4b0be8ae35c656b469bfdc6b316ef9dd13b2b9 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Wed, 23 Apr 2025 18:08:06 -0700 Subject: [PATCH 07/13] improve responsiveness --- frontend/src/components/SearchBar.tsx | 53 ++++++++++++++------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index dccf0d9..2fedd86 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -84,32 +84,33 @@ export default function SearchBar({ setProducts, setError }: Props) { className="absolute right-6 top-1/2 transform -translate-y-1/2 text-[#00629B] text-[1.2rem] cursor-pointer" />

-
-
-
- Category - {tags.map((tag, index) => ( -
- { - if (e.target.checked) { - setTagFilters([...tagFilters, tag]); - } else { - setTagFilters(tagFilters.filter((t) => t !== tag)); - } - }} - /> - -
-
- ))} + +
+
+ Category + {tags.map((tag, index) => ( +
+ { + if (e.target.checked) { + setTagFilters([...tagFilters, tag]); + } else { + setTagFilters(tagFilters.filter((t) => t !== tag)); + } + }} + /> + +
+
+ ))} +
From 5903e43d27889bae1360d549b596a83105e62821 Mon Sep 17 00:00:00 2001 From: n1sh1thaS Date: Thu, 1 May 2025 17:18:56 -0700 Subject: [PATCH 08/13] edit tags functionality --- backend/src/controllers/products.ts | 1 + frontend/src/pages/EditProduct.tsx | 81 ++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts index 9077bc6..9e89d6f 100644 --- a/backend/src/controllers/products.ts +++ b/backend/src/controllers/products.ts @@ -204,6 +204,7 @@ export const updateProductById = [ description: req.body.description, timeUpdated: new Date(), image: newImage, + tags: req.body.tags ?? [] }; console.log("Done..."); diff --git a/frontend/src/pages/EditProduct.tsx b/frontend/src/pages/EditProduct.tsx index 1e4df09..c13403f 100644 --- a/frontend/src/pages/EditProduct.tsx +++ b/frontend/src/pages/EditProduct.tsx @@ -3,6 +3,7 @@ import { Helmet } from "react-helmet-async"; import { useNavigate, useParams } from "react-router-dom"; import { DELETE, get, patch } from "src/api/requests"; import { FirebaseContext } from "src/utils/FirebaseProvider"; +import { tags } from "src/utils/constants"; export function EditProduct() { const { id } = useParams(); @@ -13,6 +14,7 @@ export function EditProduct() { image: string; userEmail: string; description: string; + tags: string[]; }>(); const productName = useRef(null); @@ -20,6 +22,7 @@ export function EditProduct() { const productDescription = useRef(null); const productImages = useRef(null); const [currentImage, setCurrentImage] = useState(null); + const [productTags, setProductTags] = useState>([]); const [error, setError] = useState(false); @@ -32,6 +35,7 @@ export function EditProduct() { .then(async (res) => { const productData = await res.json(); setProduct(productData); + setProductTags(productData.tags); setCurrentImage(productData.image); }) .catch(() => setError(true)); @@ -52,9 +56,11 @@ export function EditProduct() { body.append("name", productName.current.value); body.append("price", productPrice.current.value); body.append("description", productDescription.current.value); + productTags.forEach((tag) => { + body.append("tags[]", tag); + }); if (user.email) body.append("userEmail", user.email); if (image) body.append("image", image); - console.log(body.get("price")); const res = await patch(`/api/products/${id}`, body); @@ -90,6 +96,23 @@ export function EditProduct() { } }; + // 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) + ) { + dropdownRef.current.hidden = true; + } + }; + document.addEventListener("mousedown", handleClickOutside); + }, []); + return ( <> @@ -166,6 +189,62 @@ export function EditProduct() { Product )}
+ {/* 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} +

+ ); + } + })} +
+
+ )} +