Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
61087da
add tag attribute
n1sh1thaS Apr 22, 2025
8335db1
select tags in add product form
n1sh1thaS Apr 22, 2025
2f7eab8
show and hide tags dropdown
n1sh1thaS Apr 22, 2025
cad6d68
merge dev into feature/search
n1sh1thaS Apr 22, 2025
94921a3
create dropdown for search bar
n1sh1thaS Apr 22, 2025
a456c76
query by keyword and tags
n1sh1thaS Apr 24, 2025
3d43200
display tags on individual product page
n1sh1thaS Apr 24, 2025
dd4b0be
improve responsiveness
n1sh1thaS Apr 24, 2025
ad38856
Merge branch 'main' of https://github.com/CSES-Open-Source/LowPriceCe…
n1sh1thaS Apr 26, 2025
5903e43
edit tags functionality
n1sh1thaS May 2, 2025
1fa0059
filter by price functionality
n1sh1thaS May 2, 2025
2fa4a76
edit dropdown visibility
n1sh1thaS May 7, 2025
6380202
Merge branch 'dev' of https://github.com/CSES-Open-Source/LowPriceCen…
n1sh1thaS May 9, 2025
6dcb957
Merge branch 'dev' of https://github.com/CSES-Open-Source/LowPriceCen…
n1sh1thaS May 12, 2025
52c8779
order by name, most recent, price
n1sh1thaS May 12, 2025
100f10b
Merge branch 'dev' of https://github.com/CSES-Open-Source/LowPriceCen…
n1sh1thaS May 15, 2025
e010873
Merge branch 'feature/search' of https://github.com/CSES-Open-Source/…
n1sh1thaS May 15, 2025
1747f06
fix icon and filter selection, add high to low sort
n1sh1thaS May 21, 2025
9fc6fe8
fix dropdown visibility
n1sh1thaS May 23, 2025
175804d
Merge branch 'dev' of https://github.com/CSES-Open-Source/LowPriceCen…
n1sh1thaS May 23, 2025
d6e1718
Merge branch 'dev' of https://github.com/CSES-Open-Source/LowPriceCen…
n1sh1thaS May 28, 2025
d90da9f
Merge branch 'feature/search' of https://github.com/CSES-Open-Source/…
n1sh1thaS May 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions backend/src/controllers/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 });
}
};
Expand All @@ -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;
Expand Down Expand Up @@ -100,6 +120,7 @@ export const addProduct = [
price,
description,
userEmail,
tags,
images,
timeCreated: new Date(),
timeUpdated: new Date(),
Expand Down Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions backend/src/models/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
});

Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
161 changes: 149 additions & 12 deletions frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
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;
setError: (error: string) => void;
}

export default function SearchBar({ setProducts, setError }: Props) {
const [dropdownHidden, setDropdownHidden] = useState<boolean>(true);
const [query, setQuery] = useState<string | null>(null);
const [tagFilters, setTagFilters] = useState<string[]>([]);
const [priceMax, setPriceMax] = useState<string>();
const [orderBy, setOrderBy] = useState<string>("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);
Expand All @@ -39,14 +61,129 @@ export default function SearchBar({ setProducts, setError }: Props) {
}
};
search();
}, [query]);
}, [query, tagFilters, priceMax, orderBy]);

// handle dropdown display
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLDivElement>(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 (
<input
type="text"
onChange={(e) => 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"
/>
<>
<div className="relative w-full my-2">
<input
type="text"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a product..."
className="w-full bg-[#F8F8F8] shadow-md p-3 pr-12 pl-6 rounded-3xl"
/>
<div ref={buttonRef}>
<FaFilter
onClick={() => {
setDropdownHidden((prev) => !prev);
}}
className="absolute right-6 top-1/2 transform -translate-y-1/2 text-[#00629B] text-[1.2rem] cursor-pointer"
/>
</div>

<div
ref={dropdownRef}
hidden={dropdownHidden}
className="absolute right-0 top-full z-20 mt-2 mr-1 w-56 px-3 bg-white rounded-md ring-1 shadow-lg ring-black/5 focus:outline-hidden"
>
<div className="py-3 max-h-35 overflow-y-auto bg-white rounded-md">
<p className="font-semibold font-inter text-base">Category</p>
{tags.map((tag, index) => (
<div key={index} className="flex flex-row gap-2">
<input
type="checkbox"
id={tag}
name={tag}
value={tag}
onChange={(e) => {
if (e.target.checked) {
setTagFilters([...tagFilters, tag]);
} else {
setTagFilters(tagFilters.filter((t) => t !== tag));
}
}}
/>
<label htmlFor={tag} className="font-inter">
{tag}
</label>
<br />
</div>
))}
<div className="my-3 border-[0.5px] border-ucsd-blue rounded-2xl" />
<p className="font-semibold font-inter text-base">Price</p>
<input
id="default-range"
type="range"
min={0}
max={1000}
step={10}
value={priceMax}
defaultValue={0}
onChange={(event) => {
setPriceMax(event.target.value);
}}
className="w-full h-2 bg-gray-200 rounded-lg mb-3 appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #00629B 0%, #00629B ${((Number(priceMax) ?? 0) / 1000) * 100}%, #e5e7eb ${((Number(priceMax) ?? 0) / 1000) * 100}%, #e5e7eb 100%)`,
}}
/>
<div className="flex flex-row items-center gap-2 max-w-50">
<p className="text-sm">$0 - </p>
<input
type="number"
min={0}
max={1000}
value={priceMax}
step={0.01}
onChange={(event) => setPriceMax(event.target.value)}
className="w-1/3 p-1 border border-gray-300 text-black text-sm rounded-md"
/>
</div>
<div className="my-3 mt-4 border-[0.5px] border-ucsd-blue rounded-2xl" />
<p className="font-semibold font-inter text-base">Sort By</p>
<div>
{Object.keys(orderMethods).map((method, index) => (
<div key={index} className="flex flex-row gap-2">
<input
type="radio"
id={method}
name="orderBy"
value={method}
checked={orderBy === method}
onChange={(e) => {
if (e.target.checked) {
setOrderBy(method);
}
}}
/>
<label htmlFor={method} className="font-inter">
{" "}
{method}
</label>
<br />
</div>
))}
</div>
</div>
</div>
</div>
</>
);
}
Loading