diff --git a/public/assets/images/noResult.png b/public/assets/images/noResult.png new file mode 100644 index 00000000..d855c7f5 Binary files /dev/null and b/public/assets/images/noResult.png differ diff --git a/src/App.scss b/src/App.scss index bdabd0fb..e790fdbd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -20,4 +20,6 @@ @import "./assets/styles/SingleProduct.scss"; @import "./assets/styles/ImageSlider.scss"; @import "./assets/styles/Queries.scss"; -@import "./assets/styles/Notifications.scss"; \ No newline at end of file +@import "./assets/styles/Notifications.scss";; +@import "./assets/styles/Search.scss"; +@import "./assets/styles/CustomSelect.scss"; diff --git a/src/assets/styles/CustomSelect.scss b/src/assets/styles/CustomSelect.scss new file mode 100644 index 00000000..9650c69b --- /dev/null +++ b/src/assets/styles/CustomSelect.scss @@ -0,0 +1,37 @@ +.custom-select-wrapper { + position: relative; + width: 20rem; + .custom-select { + cursor: pointer; + } + .select-selected { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + border: 1px solid rgba(255, 109, 24, 0.49); + background-color: #fff; + } + .dropdown-icon { + font-size: 24px; + } + .select-items { + position: absolute; + width: 100%; + max-height: 10rem; + overflow-y: auto; + border: 1px solid #ccc; + border-top: none; + background-color: #fff; + z-index: 1; + } + .select-option { + padding: 10px; + cursor: pointer; + } + .select-option:hover { + background-color: #f1f1f1; + } +} diff --git a/src/assets/styles/Search.scss b/src/assets/styles/Search.scss new file mode 100644 index 00000000..31efe124 --- /dev/null +++ b/src/assets/styles/Search.scss @@ -0,0 +1,91 @@ +.wrappers { + background-color: $container-color; + + .upper-side { + display: flex; + align-items: center; + font-size: 1.5rem; + padding: 2rem 5rem; + border-bottom: 2px solid rgba(119, 119, 119, 0.49); + + .product-name { + font-size: 2rem; + font-weight: 700; + color: $text-color; + margin-right: 2rem; + } + + .filter-option { + display: flex; + gap: 10rem; + + .selection-part { + display: flex; + align-items: center; + gap: 2rem; + font-size: 2rem; + font-weight: 700; + color: $text-color; + + } + + .search-Span-price { + font-size: 2rem; + font-weight: 700; + color: $text-color; + margin-right: 2rem; + } + + .dropdown-select { + + gap: 2rem; + font-size: 2rem; + font-weight: 700; + color: $text-color; + border: 1px solid rgba(255, 109, 24, 0.49); + border-radius: 4px; + padding: 0.5rem 2rem; + font-size: 15px; + cursor: pointer; + } + } + + .discount-display { + font-size: 2rem; + display: flex; + align-items: center; + } + } + + .product-main { + display: flex; + + .product-list { + display: flex; + flex-wrap: wrap; + margin: 3rem 3rem; + margin-left: 5rem; + gap: 3rem; + } + + .loader-spinner { + display: flex; + justify-content: center; + align-items: center; + margin-left: 70rem; + margin-top: 20rem; + } + + .noResult { + margin: 0 auto; + display: flex; + flex-direction: column; + margin-bottom: 1rem; + + img { + width: auto; + height: 400px; + } + } + } +} \ No newline at end of file diff --git a/src/assets/styles/SearchInput.scss b/src/assets/styles/SearchInput.scss index 520c43df..c27bb2ad 100644 --- a/src/assets/styles/SearchInput.scss +++ b/src/assets/styles/SearchInput.scss @@ -1,56 +1,89 @@ -@import "./Colors"; - -.search-container { - display: flex; - align-items: center; - border: 0.1rem solid $primary-color-dark; - border-radius: 10rem; - overflow: hidden; + +.main-search { + gap:2rem; position: relative; - .search-icon { - font-size: 2.4rem; - margin-left: -2rem; + .search-container { display: flex; - color: $primary-color; align-items: center; - justify-content: center; - position: relative; - cursor: pointer; - z-index: 1; - } + border: 0.1rem solid $primary-color-dark; + border-radius: 10rem; + overflow: hidden; - input { - border: none; - outline: none; - font-family: inherit; - font-size: 1.6rem; - flex: 1; - background-color: transparent; - - &::placeholder { - color: $secondary-color; + .search-icon { + font-size: 2.4rem; + padding-left:1rem; + display: flex; + color: $primary-color; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + z-index: 1; } - } - .search-button { - background-color: $primary-color; - border: none; - padding: 0.8rem 4.8rem; - color: $white; - font-size: 1.6rem; - font-weight: 600; - cursor: pointer; - position: relative; - z-index: 1; - transition: all 0.2s ease-in; - - &:hover { - background-color: darken($primary-color, 10%); + input { + border: none; + outline: none; + font-family: inherit; + font-size: 1.6rem; + flex: 1; + background-color: transparent; + + &::placeholder { + color: $secondary-color; + } } - &:focus { - outline: none; + .search-button { + background-color: $primary-color; + border: none; + padding: 0.8rem 4.8rem; + color: $white; + font-size: 1.6rem; + font-weight: 600; + cursor: pointer; + position: relative; + z-index: 1; + transition: all 0.2s ease-in; + + &:hover { + background-color: darken($primary-color, 10%); + } + + &:focus { + outline: none; + } + } + } + .search-result { + position: absolute; + width: 36.5rem; + max-height: 28em; + left: 0.5rem; + z-index: 1000; + margin-top: 0.1rem; + padding: 1rem; + font-size: 14px; + font-weight: 500; + background-color: $white; + overflow-y: auto; + text-decoration: none; + .result { + display: flex; + flex-direction: column; + padding: 13px; + border: 1px solid none; + margin-top: 1px; + } + .result:hover { + background: rgb(242, 240, 240); + padding: 15px; + cursor: pointer; } } + .link{ + text-decoration: none; + color: black; + } } \ No newline at end of file diff --git a/src/components/Dropdown/CustomSelect.tsx b/src/components/Dropdown/CustomSelect.tsx new file mode 100644 index 00000000..28482741 --- /dev/null +++ b/src/components/Dropdown/CustomSelect.tsx @@ -0,0 +1,71 @@ +/* eslint-disable */ +import React, { useState, useEffect, useRef } from 'react'; +import { RiArrowDropDownLine } from 'react-icons/ri'; + +interface Option { + label: string; + value: string; +} + +interface CustomSelectProps { + options: Option[]; + onSelect: (selected: string) => void; + value: string; +} + +const CustomSelect: React.FC = ({ options, onSelect, value }) => { + const [selectedLabel, setSelectedLabel] = useState('Select an option'); + const [isOpen, setIsOpen] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + const selectedOption = options.find(option => option.value === value); + if (selectedOption) { + setSelectedLabel(selectedOption.label); + } + }, [value, options]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleOptionClick = (option: Option) => { + setSelectedLabel(option.label); + setIsOpen(false); + onSelect(option.value); + }; + + return ( +
+
setIsOpen(!isOpen)}> +
+ {selectedLabel} + +
+ {isOpen && ( +
+ {options.map((option, index) => ( +
handleOptionClick(option)} + > + {option.label} +
+ ))} +
+ )} +
+
+ ); +}; + +export default CustomSelect; \ No newline at end of file diff --git a/src/components/buttons/Button2.tsx b/src/components/buttons/Button2.tsx new file mode 100644 index 00000000..4371da16 --- /dev/null +++ b/src/components/buttons/Button2.tsx @@ -0,0 +1,9 @@ +/* eslint-disable */ + +import React from "react"; + +const Button = ({ title }: { title: string }) => ( + +); + +export default Button; diff --git a/src/components/inputs/Input.tsx b/src/components/inputs/Input.tsx index a3ff2658..37c95d33 100644 --- a/src/components/inputs/Input.tsx +++ b/src/components/inputs/Input.tsx @@ -1,7 +1,6 @@ /* eslint-disable */ import React, { ReactNode } from "react"; -import "../../styles/Input.scss"; interface InputLabelProps extends React.InputHTMLAttributes { label: string; diff --git a/src/components/inputs/SearchInput.tsx b/src/components/inputs/SearchInput.tsx index 6d41b7fa..3269eba1 100644 --- a/src/components/inputs/SearchInput.tsx +++ b/src/components/inputs/SearchInput.tsx @@ -1,21 +1,99 @@ /* eslint-disable */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { FiSearch } from "react-icons/fi"; - +import { useAppDispatch, useAppSelector } from "../../store/store"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { searchProduct } from "../../store/features/product/productSlice"; interface SearchInputProps { - className?: string; + className: string; placeholder?: string; } + function SearchInput({ className, placeholder }: SearchInputProps) { + const dispatch = useAppDispatch(); + const { products } = useAppSelector((state) => state.products); + const [search, setSearch] = useState(""); + const [isFocused, setIsFocused] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const navigate = useNavigate(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearch(value); + dispatch(searchProduct({ name: value.trim() })); + setIsFocused(true); + }; + + const handleProductClick = (name: string) => { + setSearch(name); + setIsFocused(false); + setIsHovered(false); + navigate(`/search?name=${name}`); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (search.trim()) { + navigate(`/search?name=${search.trim()}`); + } + }; + + useEffect(() => { + if (!search.trim()) { + setIsFocused(false); + setIsHovered(false); + } + }, [search]); + + const filteredProducts = products?.filter((product: any) => + product.name.toLowerCase().includes(search.toLowerCase()) + ); + return ( -
-
- -
- - -
+
+
+
+ +
+ setIsFocused(true)} + onBlur={() => { + if (!isHovered) { + setIsFocused(false); + } + }} + onChange={handleSearchChange} + value={search} + /> + +
+ {isFocused && search.trim() && ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {filteredProducts && filteredProducts.length > 0 ? ( + filteredProducts.map((product: any) => ( +
+
handleProductClick(product.name)}> +

{product.name}

+
+
+ )) + ) : ( +
+
+ )} +
+ )} +
); } diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx new file mode 100644 index 00000000..81d27c5e --- /dev/null +++ b/src/pages/Search.tsx @@ -0,0 +1,212 @@ +/* eslint-disable */ +import React, { useEffect, useState } from "react"; +import { useAppDispatch, useAppSelector } from "../store/store"; +import { useLocation, useNavigate } from "react-router-dom"; +import noresults from "../../public/assets/images/noResult.png"; +import Product from "../components/product/Product"; +import { searchProduct } from "../store/features/product/productSlice"; +import { PuffLoader } from "react-spinners"; +import CustomSelect from "../components/Dropdown/CustomSelect"; + +interface Option { + label: string; + value: string; +} + +const SearchBar: React.FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + const params = new URLSearchParams(location.search); + const initialName = params.get("name") || ""; + const initialCategory = params.get("category") || ""; + const initialMaxPrice = params.get("maxPrice") || ""; + const initialMinPrice = params.get("minPrice") || ""; + const initialDiscount = params.get("discount") || ""; + + const [name, setName] = useState(initialName); + const [category, setCategory] = useState(initialCategory); + const [maxPrice, setMaxPrice] = useState(initialMaxPrice); + const [minPrice, setMinPrice] = useState(initialMinPrice); + const [discount, setDiscount] = useState(initialDiscount); + const [selectPrice, setSelectPrice] = useState>([]); + const [discountOptions, setDiscountOptions] = useState([]); + const [minPriceOptions, setMinPriceOptions] = useState([]); + const [maxPriceOptions, setMaxPriceOptions] = useState([]); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch( + searchProduct({ + name: name || undefined, + category: category || undefined, + maxPrice: maxPrice ? parseInt(maxPrice) : undefined, + minPrice: minPrice ? parseInt(minPrice) : undefined, + discount: discount ? parseInt(discount) : undefined, + }) + ); + }, [dispatch, name, category, maxPrice, minPrice, discount]); + + const { isSuccess, isError, isLoading, products } = useAppSelector( + (state) => state.products + ); + + useEffect(() => { + if (products && products.length > 0) { + const sortedPrices = Array.from( + new Set(products.map((product: any) => product.price)) + ).sort((a, b) => a - b); + setSelectPrice(sortedPrices); + + const priceOptions = sortedPrices.map(price => ({ label: `$${price}`, value: price.toString() })); + setMinPriceOptions([{ label: 'Min', value: '' }, ...priceOptions]); + setMaxPriceOptions([{ label: 'Max', value: '' }, ...priceOptions.slice(1)]); + + const sortedDiscounts = Array.from( + new Set(products.map((product: any) => product.discount)) + ) + .sort((a, b) => parseInt(a) - parseInt(b)) + .map((discount) => ({ label: `${discount}`, value: discount })); + setDiscountOptions([{ label: 'Discount', value: '' }, ...sortedDiscounts]); + } + }, [products]); + + const handleFilterChange = (filter: string, value: string) => { + const newParams = new URLSearchParams(location.search); + if (value) { + newParams.set(filter, value); + } else { + newParams.delete(filter); + } + navigate({ search: newParams.toString() }); + }; + + useEffect(() => { + setName(initialName); + setCategory(initialCategory); + setMaxPrice(initialMaxPrice); + setMinPrice(initialMinPrice); + setDiscount(initialDiscount); + }, [ + initialName, + initialCategory, + initialMaxPrice, + initialMinPrice, + initialDiscount, + ]); + + const filteredProducts = products + ?.filter( + (product: any) => + product.name.toLowerCase().includes(name.toLowerCase()) && + (!category || product.category === category) && + (!maxPrice || product.price <= parseInt(maxPrice)) && + (!minPrice || product.price >= parseInt(minPrice)) && + (!discount || parseInt(product.discount) >= parseInt(discount)) + ) + .sort((a: any, b: any) => a.price - b.price); + + useEffect(() => { + if (filteredProducts && filteredProducts.length > 0) { + const sortedPrices = Array.from( + new Set(filteredProducts.map((product: any) => product.price)) + ).sort((a, b) => a - b); + setSelectPrice(sortedPrices); + + const priceOptions = sortedPrices.map(price => ({ label: `$${price}`, value: price.toString() })); + setMinPriceOptions([{ label: 'Min', value: '' }, ...priceOptions]); + setMaxPriceOptions([{ label: 'Max', value: '' }, ...priceOptions.slice(1)]); + + const sortedDiscounts = Array.from( + new Set(filteredProducts.map((product: any) => product.discount)) + ) + .sort((a, b) => parseInt(a) - parseInt(b)) + .map((discount) => ({ label: `${discount}`, value: discount })); + setDiscountOptions([{ label: 'Discount', value: '' }, ...sortedDiscounts]); + } + }, [filteredProducts]); + + return ( + <> +
+
+
+
+ {name}: +
+
+ Price: + { + setMinPrice(selected); + handleFilterChange("minPrice", selected); + }} + value={minPrice} + /> + { + setMaxPrice(selected); + handleFilterChange("maxPrice", selected); + }} + value={maxPrice} + /> +
+
+ Discount: + { + setDiscount(selected); + handleFilterChange("discount", selected); + }} + value={discount} + /> +
+
+
+
+ {isLoading ? ( +
+ +
+ ) : isSuccess && + filteredProducts && + filteredProducts.length > 0 ? ( +
+ {filteredProducts.map((product:any) => ( + + ))} +
+ ) : isError ? ( +
+

Something went wrong. Please try again later.

+
+ ) : ( +
+
+ No results +
+
+

No products found matching your search criteria.

+
+
+ )} +
+
+
+
+ + ); +}; + +export default SearchBar; \ No newline at end of file diff --git a/src/router.tsx b/src/router.tsx index 44229223..aba832ce 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,6 +1,7 @@ +/* eslint-disable*/ /* eslint-disable */ import React from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, } from "react-router-dom"; import LandingPage from "./pages/LandingPage"; import { SignUp } from "./pages/SignUp"; import NotFound from "./pages/NotFound"; @@ -16,6 +17,7 @@ import ViewProduct from "./pages/ViewProduct"; import UserLogin from "./pages/UserLogin"; import SellerLogin from "./pages/SellerLogin"; import AdminLogin from "./pages/AdminLogin"; +import Search from "./pages/Search"; const AppRouter: React.FC = () => { return ( @@ -49,11 +51,14 @@ const AppRouter: React.FC = () => { element={} /> } /> - } /> + }/> + } /> + ); }; export default AppRouter; + diff --git a/src/store/features/product/productService.tsx b/src/store/features/product/productService.tsx index b5fab56d..b2c170a8 100644 --- a/src/store/features/product/productService.tsx +++ b/src/store/features/product/productService.tsx @@ -1,13 +1,9 @@ /* eslint-disable */ -import {axiosInstance} from "../../../utils/axios/axiosInstance"; +import {axiosInstance,getErrorMessage} from "../../../utils/axios/axiosInstance"; const fetchProducts = async () => { - try { const response = await axiosInstance.get(`/api/shop/user-get-products`); return response.data; - } catch (error) { - throw new Error('Failed to fetch products.'); - } }; const fetchSingleProduct = async (id: string) => { @@ -36,11 +32,16 @@ const fetchShopInfo = async (id: string) => { throw new Error('Failed to fetch shops.'); } }; +const searchProduct = async(criteria:any)=>{ + const response = await axiosInstance.get(`/api/shop/user-search-products?${criteria}`); + return response.data; +} const productService = { fetchProducts, fetchSingleProduct, fetchProductReviews, - fetchShopInfo + fetchShopInfo, + searchProduct } export default productService; \ No newline at end of file diff --git a/src/store/features/product/productSlice.tsx b/src/store/features/product/productSlice.tsx index faa28776..9a58918a 100644 --- a/src/store/features/product/productSlice.tsx +++ b/src/store/features/product/productSlice.tsx @@ -1,24 +1,34 @@ /* eslint-disable */ import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; import productService from "./productService"; -import { IProduct } from "../../../utils/types/product"; +import { IProduct, SearchCriteria } from "../../../utils/types/store"; +import { getErrorMessage } from "../../../utils/axios/axiosInstance"; -const initialState: { products: IProduct[] | null; isLoading: boolean; isError: string | null; isSuccess: boolean; message: string } = { - products: null, +const initialState: { products: IProduct[] | null; isLoading: boolean; isError: boolean | null; isSuccess: boolean; message: string } = { + products:[], isLoading: false, - isError: null, + isError: false, isSuccess: false, message: '' } -export const fetchProducts = createAsyncThunk("products/fetchProducts", async () => { +export const fetchProducts = createAsyncThunk("products/fetchProducts", async (_,thunkApi) => { try { const response = await productService.fetchProducts(); return response.data; } catch (error) { - throw new Error('Failed to fetch products.'); + return thunkApi.rejectWithValue(getErrorMessage(error)); } }); +export const searchProduct = createAsyncThunk("product/searchProduct", async (criteria,thunkApi) => { + try { + const response = await productService.searchProduct(criteria); + return response.data; + } catch (error) { + return thunkApi.rejectWithValue(getErrorMessage(error)); + } + } + ); const productSlice = createSlice({ @@ -29,17 +39,35 @@ const productSlice = createSlice({ builder .addCase(fetchProducts.pending, (state) => { state.isLoading = true; - state.isError = null; + state.isError = false; state.isSuccess = false; }) .addCase(fetchProducts.fulfilled, (state, action: PayloadAction) => { state.isLoading = false; state.isSuccess = true; - state.products = action.payload; + state.products = action.payload.products; }) .addCase(fetchProducts.rejected, (state, action: PayloadAction) => { state.isLoading = false; - state.isError = action.payload; + state.isError = true; + state.message = action.payload; + state.isSuccess = false; + }) + .addCase(searchProduct.pending, (state) => { + state.isLoading = true; + state.isSuccess = false; + }) + .addCase(searchProduct.fulfilled, (state, action: PayloadAction) => { + state.isLoading = false; + state.isSuccess = true; + state.products = action.payload.products; + console.log(action.payload); + + }) + .addCase(searchProduct.rejected, (state, action: PayloadAction) => { + state.isLoading = false; + state.isError = true; + state.message = action.payload; state.isSuccess = false; }); } diff --git a/src/store/store.ts b/src/store/store.ts index 7c592aaa..d1ceed11 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,7 +2,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import welcomeReducer from "./features/welcomeSlice"; -import productReducer from './features/product/productSlice'; +import productReducer from './features/product/productSlice' import authReducer from './features/auth/authSlice'; import singleProductReducer from './features/product/singleProductSlice'; import notificationReducer from './features/notifications/notificationSlice'; diff --git a/src/utils/types/product.d.ts b/src/utils/types/product.d.ts index 0bac376a..db4f7671 100644 --- a/src/utils/types/product.d.ts +++ b/src/utils/types/product.d.ts @@ -1,44 +1,56 @@ -/* eslint-disable @typescript-eslint/indent */ -export interface IProduct { - id: string; - shopId: string; - name: string; - description?: string; - price: number; - discount?: string; - category: string; - expiryDate?: Date; - expired: boolean; - bonus?: string; - images: string[]; - quantity: number; - status: string; - createdAt: Date; - updatedAt: Date; - productReviews: IProductReview[]; - shops: IShop; -} - -export interface IProductReview { - id: string; - productId: string; - userId: string; - feedback: string; - rating: number; - status: boolean; - createdAt: Date; - updatedAt: Date; - user?: { - firstName?: string; - lastName?: string; - profilePicture?: string; - } -} - -export interface IShop { - id: string; - userId: string; - name: string; - description?: string; - image?: string; -} \ No newline at end of file +/* eslint-disable */ +export interface IProducts { + nextPage:number; + currentPage: number; + previousPage:number; + limit:number; + data:[]; + error?:string + } + export interface IProductsState { + searchProduct: searchProduct; + } + + export interface IProduct { + id: string; + shopId: string; + name: string; + description?: string; + price: number; + discount?: string; + category: string; + expiryDate?: Date; + expired: boolean; + bonus?: string; + images: string[]; + quantity: number; + status: string; + createdAt: Date; + updatedAt: Date; + productReviews: IProductReview[]; + shops: IShop; + } + + export interface IProductReview { + id: string; + productId: string; + userId: string; + feedback: string; + rating: number; + status: boolean; + createdAt: Date; + updatedAt: Date; + user?: { + firstName?: string; + lastName?: string; + profilePicture?: string; + } + } + + export interface IShop { + id: string; + userId: string; + name: string; + description?: string; + image?: string; + } \ No newline at end of file diff --git a/src/utils/types/store.d.ts b/src/utils/types/store.d.ts index 9c329078..0ad93e74 100644 --- a/src/utils/types/store.d.ts +++ b/src/utils/types/store.d.ts @@ -96,4 +96,12 @@ export interface INotificationInitialResource { message: string | null, passwordExpiryMessage: string | null, isLoggedOut: boolean -} \ No newline at end of file +} + +export interface SearchCriteria { + name?: string; + category?: string; + minPrice?: number; + maxPrice?: number; + discount?: number; +}