Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 23 additions & 5 deletions backend/src/controllers/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,33 @@ 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 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);
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 +85,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 +116,7 @@ export const addProduct = [
price,
description,
userEmail,
tags,
images,
timeCreated: new Date(),
timeUpdated: new Date(),
Expand Down Expand Up @@ -190,6 +207,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
127 changes: 115 additions & 12 deletions frontend/src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
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;
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>();

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) {
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}`,
).then((res) => {
if (res.ok) {
res.json().then((data) => {
setProducts(data);
Expand All @@ -39,14 +54,102 @@ export default function SearchBar({ setProducts, setError }: Props) {
}
};
search();
}, [query]);
}, [query, tagFilters, priceMax]);

// 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(false);
}
};
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={() => {
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"
/>
</div>

<div
ref={dropdownRef}
hidden={dropdownHidden}
className="absolute right-0 top-full z-10 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 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>
</div>
</div>
</>
);
}
81 changes: 80 additions & 1 deletion frontend/src/pages/AddProduct.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { FormEvent, useContext, useRef, useState } from "react";
import { FormEvent, useRef, useContext, useState, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate } from "react-router-dom";
import { post } from "src/api/requests";
import { FirebaseContext } from "src/utils/FirebaseProvider";
import { tags } from "../utils/constants.tsx";

export function AddProduct() {
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
Expand All @@ -11,6 +12,7 @@ export function AddProduct() {
const productPrice = useRef<HTMLInputElement>(null);
const productDescription = useRef<HTMLTextAreaElement>(null);
const productImages = useRef<HTMLInputElement>(null);
const [productTags, setProductTags] = useState<Array<string>>([]);

const { user } = useContext(FirebaseContext);
const [error, setError] = useState<boolean>(false);
Expand Down Expand Up @@ -51,6 +53,23 @@ export function AddProduct() {
setNewPreviews((p) => p.filter((_, i) => i !== idx));
};

// 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)
) {
dropdownRef.current.hidden = true;
}
};
document.addEventListener("mousedown", handleClickOutside);
}, []);

const handleSubmit = async (e: FormEvent) => {
if (isSubmitting) return;
setIsSubmitting(true);
Expand All @@ -66,6 +85,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 (productImages.current && productImages.current.files) {
Expand Down Expand Up @@ -146,6 +168,63 @@ export function AddProduct() {
/>
</div>

{/* Product Tags */}
<div className="mb-5">
<label htmlFor="productTags" className="block mb-2 font-medium font-inter text-black">
Tags
</label>
<div
id="productTags"
className="flex flex-row max-w-full flex-wrap gap-2 border border-gray-300 text-black text-sm rounded-md w-full p-2.5 min-h-10 hover:cursor-pointer"
onClick={() => {
if (dropdownRef.current) dropdownRef.current.hidden = false;
}}
ref={buttonRef}
>
{productTags.map((tag) => (
<div
key={tag}
className="flex items-center gap-2 p-1 px-2 w-fit bg-slate-200 rounded-2xl"
>
<span className="text-sm font-medium">{tag}</span>
<button
className="flex items-center justify-center p-1 h-5 w-5 bg-slate-300 text-md rounded-full hover:bg-blue-400 hover:text-white transition-colors duration-200"
onClick={() => {
setProductTags(productTags.filter((t) => t !== tag));
}}
>
×
</button>
</div>
))}
</div>
{/* Product Tags Dropdown */}
{productTags.length !== tags.length && (
<div
ref={dropdownRef}
className="z-10 mt-2 min-w-64 origin-top-right rounded-md ring-1 shadow-lg ring-black/5 focus:outline-hidden"
>
<div className="py-1 max-h-35 overflow-y-auto">
{tags.map((tag) => {
if (!productTags.includes(tag)) {
return (
<p
onClick={() => {
setProductTags([...productTags, tag]);
}}
key={tag}
className="px-4 py-2 text-sm text-gray-700 hover:cursor-pointer"
>
{tag}
</p>
);
}
})}
</div>
</div>
)}
</div>

{/* Images */}
<div className="mb-5">
<label htmlFor="productImages" className="block mb-2 font-medium font-inter text-black">
Expand Down
Loading