diff --git a/backend/src/controllers/products.ts b/backend/src/controllers/products.ts
index 3c00ae0..d06153c 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 category
+ 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 });
@@ -52,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" });
}
@@ -63,19 +184,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 +224,8 @@ export const addProduct = [
description,
userEmail,
images,
+ condition,
+ tags,
timeCreated: new Date(),
timeUpdated: new Date(),
});
@@ -168,6 +293,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 +314,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/.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/components/FilterSort.tsx b/frontend/src/components/FilterSort.tsx
new file mode 100644
index 0000000..dd5fa69
--- /dev/null
+++ b/frontend/src/components/FilterSort.tsx
@@ -0,0 +1,220 @@
+import React, { useState } from 'react';
+
+interface FilterBarProps {
+ filters: any;
+ setFilters: (filters: any) => void;
+}
+
+const TAGS = [
+ 'Electronics',
+ 'School Supplies',
+ 'Dorm Essentials',
+ 'Furniture',
+ 'Clothes',
+ 'Miscellaneous',
+];
+
+function Chevron({ open, className = '' }: { open: boolean; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+export default function FilterBar({ filters, setFilters }: FilterBarProps) {
+ const [categoryOpen, setCategoryOpen] = useState(true);
+ const [filterOpen, setFilterOpen] = useState(true);
+ const [conditionOpen, setConditionOpen] = useState(false);
+ const [priceOpen, setPriceOpen] = useState(true);
+
+ 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', tags: [] });
+ };
+
+ return (
+
+
+ {/* ══ CATEGORY ══ */}
+
+
setCategoryOpen((o) => !o)}
+ className="flex items-center gap-2 w-full py-1 group"
+ >
+ {/* Box icon matching mockup */}
+
+
+
+
+ {!categoryOpen && (
+
+ )}
+
+
+ Category
+
+
+
+ {categoryOpen && (
+
+ {/* Continuous gold vertical spine */}
+
+
+ {TAGS.map((tag, i) => {
+ const active = filters.tags?.includes(tag);
+ const isLast = i === TAGS.length - 1;
+ return (
+
+ {/* Clip spine past midpoint of last row */}
+ {isLast && (
+
+ )}
+
handleTagToggle(tag)}
+ className="flex items-center w-full text-left py-[5px] pl-5 group"
+ >
+ {/* Horizontal gold branch */}
+
+
+ {tag}
+
+ {active && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* ══ FILTER ══ */}
+
+
setFilterOpen((o) => !o)}
+ className="flex items-center gap-2 w-full py-1 group"
+ >
+ {/* Funnel icon */}
+
+
+
+
+
+ Filter
+
+
+
+ {filterOpen && (
+
+
+ {/* border-l ends exactly at the midpoint of the Price Range row.
+ A 17px spacer inside the container represents the top half of
+ that row. Price Range button is then pulled up via -mt to overlap it. */}
+
+
+ {/* ── Condition ── */}
+
+
setConditionOpen((o) => !o)}
+ className="flex items-center w-full text-left py-[5px] group"
+ >
+
+
+ Condition
+
+
+
+ {conditionOpen && (
+
+ setFilters({ ...filters, condition: e.target.value })}
+ >
+ All
+ New
+ Used
+
+
+ )}
+
+
+ {/* Top half of Price Range row height — spine ends here */}
+
+
+
+
+ {/* Price Range button — pulled up by 17px to sit over the spacer,
+ so its text visually aligns with where the branch meets the spine */}
+
setPriceOpen((o) => !o)}
+ className="flex items-center w-full text-left py-[5px] group"
+ style={{ marginTop: '-16px' }}
+ >
+
+
+ Price Range
+
+
+
+
+ {/* Price Range inputs */}
+ {priceOpen && (
+
+ )}
+
+
+ )}
+
+
+ {/* Clear Filters */}
+
+ Clear All Filters
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/FilterSort_old.tsx b/frontend/src/components/FilterSort_old.tsx
new file mode 100644
index 0000000..117ec48
--- /dev/null
+++ b/frontend/src/components/FilterSort_old.tsx
@@ -0,0 +1,232 @@
+import React, { useState } from 'react';
+
+interface FilterBarProps {
+ filters: any;
+ setFilters: (filters: any) => void;
+}
+
+const TAGS = [
+ 'Electronics',
+ 'School Supplies',
+ 'Dorm Essentials',
+ 'Furniture',
+ 'Clothes',
+ 'Miscellaneous',
+];
+
+function Chevron({ open }: { open: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function TreeItem({
+ label,
+ isLast,
+ active,
+ onClick,
+ right,
+}: {
+ label: string;
+ isLast?: boolean;
+ active?: boolean;
+ onClick: () => void;
+ right?: React.ReactNode;
+}) {
+ return (
+
+ {/* Vertical gold spine — clips at midpoint for last item */}
+
+
+ {/* Horizontal gold branch */}
+
+
+ {label}
+
+ {active && (
+
+ )}
+ {right}
+
+
+ );
+}
+
+export default function FilterBar({ filters, setFilters }: FilterBarProps) {
+ const [categoryOpen, setCategoryOpen] = useState(true);
+ const [filterOpen, setFilterOpen] = useState(true);
+ const [conditionOpen, setConditionOpen] = useState(false);
+ const [priceOpen, setPriceOpen] = useState(false);
+
+ 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', tags: [] });
+ };
+
+ const sectionHeaderClass =
+ 'flex items-center gap-2 w-full py-2 hover:opacity-80 transition-opacity';
+ const iconBoxClass =
+ 'flex items-center justify-center w-5 h-5 rounded border border-ucsd-blue text-ucsd-blue shrink-0';
+
+ return (
+
+
+ {/* ══ CATEGORY ══ */}
+
setCategoryOpen((o) => !o)}
+ className={sectionHeaderClass}
+ >
+
+
+
+
+ {!categoryOpen && (
+
+ )}
+
+
+ Category
+
+
+
+ {categoryOpen && (
+
+ {TAGS.map((tag, i) => (
+ handleTagToggle(tag)}
+ />
+ ))}
+
+ )}
+
+ {/* ══ FILTER ══ */}
+
setFilterOpen((o) => !o)}
+ className={sectionHeaderClass}
+ >
+
+ {/* Funnel / filter icon */}
+
+
+
+
+ Filter
+
+
+
+ {filterOpen && (
+
+
+ {/* Condition */}
+
setConditionOpen((o) => !o)}
+ right={ }
+ />
+ {conditionOpen && (
+
+ setFilters({ ...filters, condition: e.target.value })}
+ >
+ All
+ New
+ Used
+
+
+ )}
+
+ {/* Price Range */}
+ setPriceOpen((o) => !o)}
+ right={ }
+ />
+ {priceOpen && (
+
+ )}
+
+
+
+ )}
+
+ {/* Clear Filters */}
+
+ Clear All Filters
+
+
+ );
+}
+
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index b35b3c6..0167fd1 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -1,6 +1,6 @@
-import { faBars, faCartShopping, faUser, faXmark, faHeart } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useContext, useEffect, useRef, useState } from "react";
+import { faBars, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FirebaseContext } from "src/utils/FirebaseProvider";
export function Navbar() {
@@ -9,9 +9,7 @@ export function Navbar() {
const menuRef = useRef(null);
const buttonRef = useRef(null);
- const toggleMobileMenu = () => {
- setMobileMenuOpen(!isMobileMenuOpen);
- };
+ const toggleMobileMenu = () => setMobileMenuOpen((o) => !o);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -24,141 +22,192 @@ export function Navbar() {
setMobileMenuOpen(false);
}
};
-
const handleResize = () => {
- if (window.matchMedia("(min-width: 768px)").matches) {
- setMobileMenuOpen(false);
- }
+ if (window.matchMedia("(min-width: 768px)").matches) setMobileMenuOpen(false);
};
-
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("resize", handleResize);
-
return () => {
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("resize", handleResize);
};
}, []);
+ // Shared circular icon button style
+ const iconBtn =
+ "w-9 h-9 rounded-full border border-gray-200 flex items-center justify-center text-gray-500 hover:text-ucsd-blue hover:border-ucsd-blue transition";
+
return (
<>
-
- {/* Desktop View */}
+
+
+ {/* ── Brand ── */}
(window.location.href = "/products")}
>
- Low-Price Center
+ Low
+ Price Center
-
+
+ {/* ── Desktop centre nav links ── */}
+
(window.location.href = "/products")}
- className="font-inter px-4 py-1 bg-transparent border-transparent rounded hover:bg-ucsd-darkblue transition-colors"
+ className="text-ucsd-blue font-semibold border-b-2 border-ucsd-blue pb-0.5 transition-colors"
>
-
- Products
+ Shop
(window.location.href = "/saved-products")}
- className="font-inter px-4 py-1 bg-transparent border-transparent rounded hover:bg-ucsd-darkblue transition-colors"
+ onClick={() => (window.location.href = "/add-product")}
+ className="hover:text-ucsd-blue transition-colors"
>
-
- Saved
+ Sell
- {user ? (
-
-
- Sign Out
-
- ) : (
-
-
- Sign In
-
- )}
+ (window.location.href = "/student-organizations")}
+ className="hover:text-ucsd-blue transition-colors"
+ >
+ Student Organizations
+
- {/* Mobile View */}
+ {/* ── Desktop right icons ── */}
+
+ {/* Saved / Heart */}
+
(window.location.href = "/saved-products")}
+ title="Saved"
+ className={iconBtn}
+ >
+
+
+
+
+
+ {/* Cart / Products */}
+
(window.location.href = "/products")}
+ title="Products"
+ className={iconBtn}
+ >
+
+
+
+
+
+
+
+ {/* User avatar / Sign in */}
+ {user ? (
+
+ {user.displayName?.[0]?.toUpperCase() ?? "U"}
+
+ ) : (
+
+ Sign In
+
+ )}
+
+
+ {/* ── Mobile hamburger ── */}
-
+
+
-
+
+ (window.location.href = "/marketplace")}
+ className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 text-ucsd-blue font-semibold transition"
+ >
+ Shop
+
+
+
+ (window.location.href = "/add-product")}
+ className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition"
+ >
+ Sell
+
+
+
+ (window.location.href = "/student-organizations")}
+ className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition"
+ >
+ Student Organizations
+
+
+
+ (window.location.href = "/saved-products")}
+ className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition"
+ >
+ Saved
+
+
+
(window.location.href = "/products")}
- className="font-inter w-full text-center px-4 py-2 bg-transparent border-transparent rounded hover:bg-ucsd-darkblue transition-colors"
+ className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-50 transition"
>
-
Products
-
+
{user ? (
-
Sign Out
) : (
-
Sign In
)}
+
- {/* Mobile Menu Blur Effect */}
+ {/* Mobile backdrop */}
{isMobileMenuOpen && (
setMobileMenuOpen(false)}
/>
)}
>
);
-}
+}
\ 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/components/index.ts b/frontend/src/components/index.ts
index 755e3c6..a4c65e6 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -1,3 +1,3 @@
// the no-relative-import-paths eslint plugin doesn't check exports
// IMO this is fine for an index file
-export { Navbar } from "./Navbar";
+export { Navbar } from "./Navbar1";
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