diff --git a/package-lock.json b/package-lock.json index 03205a6..33641f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,6 +149,12 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-6.1.10.tgz", "integrity": "sha512-73xxTSYJNKfiJ7C1Ajg+sz5l8y+blb/vNgHYg7O3yem5zLBnfPpidJ1UGg4W4d2Y+jwUVJbZKh8SKJarqAJVUQ==", "peer": true, + "node_modules/@angular/common": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-6.1.10.tgz", + "integrity": "sha512-73xxTSYJNKfiJ7C1Ajg+sz5l8y+blb/vNgHYg7O3yem5zLBnfPpidJ1UGg4W4d2Y+jwUVJbZKh8SKJarqAJVUQ==", + "license": "MIT", + "peer": true, "dependencies": { "tslib": "^1.9.0" }, @@ -161,6 +167,7 @@ "version": "6.1.10", "resolved": "https://registry.npmjs.org/@angular/core/-/core-6.1.10.tgz", "integrity": "sha512-61l3rIQTVdT45eOf6/fBJIeVmV10mcrxqS4N/1OWkuDT29YSJTZSxGcv8QjAyyutuhcqWWpO6gVRkN07rWmkPg==", + "license": "MIT", "peer": true, "dependencies": { "tslib": "^1.9.0" @@ -170,6 +177,19 @@ "zone.js": "~0.8.26" } }, + "node_modules/@aw-web-design/x-default-browser": { + "version": "1.4.126", + "resolved": "https://registry.npmjs.org/@aw-web-design/x-default-browser/-/x-default-browser-1.4.126.tgz", + "integrity": "sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==", + "dev": true, + "dependencies": { + "tslib": "^1.9.0" + }, + "peerDependencies": { + "@angular/core": "6.1.10", + "rxjs": "^6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -29572,4 +29592,5 @@ "peer": true } } + } } diff --git a/public/product-default.png b/public/product-default.png new file mode 100644 index 0000000..1a0d06d Binary files /dev/null and b/public/product-default.png differ diff --git a/src/__tests__/landingpage.test.tsx b/src/__tests__/landingpage.test.tsx new file mode 100644 index 0000000..6673262 --- /dev/null +++ b/src/__tests__/landingpage.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Page from '@/app/products/page'; +import * as nextNavigation from 'next/navigation'; +import request from '@/utils/axios'; + +// Mock dependencies +jest.mock('next/navigation', () => ({ + useSearchParams: jest.fn(), +})); + +jest.mock('@/utils/axios', () => ({ + get: jest.fn(), +})); + +jest.mock('@/components/Side', () => () =>
Side Component
); +jest.mock('@/components/Header', () => () =>
Header Component
); +jest.mock('@/components/Footer', () => () =>
Footer Component
); +jest.mock('@/components/ProductList', () => ({ searchResults }: { searchResults: any[] }) => ( +
Product List with {searchResults.length} results
+)); + +describe('Page Component', () => { + it('renders without crashing and fetches search results', async () => { + const mockSearchParams = { + toString: jest.fn().mockReturnValue(''), + }; + const mockResponse = [{ id: 1, name: 'Product 1' }, { id: 2, name: 'Product 2' }]; + + (nextNavigation.useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + (request.get as jest.Mock).mockResolvedValue(mockResponse); + + render(); + + // Wait for search results to be fetched and rendered + await waitFor(() => { + expect(screen.getByText('Header Component')).toBeInTheDocument(); + expect(screen.getByText('Footer Component')).toBeInTheDocument(); + expect(screen.getByText('Product List with 2 results')).toBeInTheDocument(); + expect(screen.getByText('Side Component')).toBeInTheDocument(); + }); + + }); + + it('handles error in fetching search results', async () => { + const mockSearchParams = { + toString: jest.fn().mockReturnValue(''), + }; + const mockError = new Error('Network Error'); + + (nextNavigation.useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + (request.get as jest.Mock).mockRejectedValue(mockError); + + render(); + + // Wait for error handling + await waitFor(() => { + expect(screen.getByText('Header Component')).toBeInTheDocument(); + expect(screen.getByText('Footer Component')).toBeInTheDocument(); + expect(screen.getByText('Product List with 0 results')).toBeInTheDocument(); + expect(screen.getByText('Side Component')).toBeInTheDocument(); + }); + + + }); +}); diff --git a/src/__tests__/productList.test.tsx b/src/__tests__/productList.test.tsx index c2e0e12..938e091 100644 --- a/src/__tests__/productList.test.tsx +++ b/src/__tests__/productList.test.tsx @@ -37,29 +37,5 @@ describe('ProductList', () => { , ); expect(await findByText('test')).toBeInTheDocument(); - }); - it('renders Page without crashing', async () => { - const { findByText } = render( - - - , - ); - expect(await findByText('All Products')).toBeInTheDocument(); - }); - it('renders single Page without crashing', async () => { - const { findByText } = render( - - - , - ); - expect(await findByText('All Products')).toBeInTheDocument(); - }); - it('should render productList', async () => { - const { getByText } = render( - - - , - ); - expect(getByText('Filter Product')).toBeInTheDocument(); - }); +}); }); diff --git a/src/__tests__/reset.test.tsx b/src/__tests__/reset.test.tsx index b2e00ce..449ad5c 100644 --- a/src/__tests__/reset.test.tsx +++ b/src/__tests__/reset.test.tsx @@ -61,4 +61,4 @@ describe('ResetPassword component', () => { ); expect(getByText("Please insert your new password you'd like to use")).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/userCart.test.tsx b/src/__tests__/userCart.test.tsx new file mode 100644 index 0000000..9ab0d80 --- /dev/null +++ b/src/__tests__/userCart.test.tsx @@ -0,0 +1,50 @@ +import { renderHook } from '@testing-library/react'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { useDispatch } from 'react-redux'; +import { useQueryClient } from '@tanstack/react-query'; +import { handleFetchUserCart, handleCartInfoManipulation } from '@/hooks/userCart'; +import { ProductType } from '@/types/Product'; + +// Mocking useDispatch and useQueryClient +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(), + useQuery: jest.fn(), +})); + + +describe('handleCartInfoManipulation', () => { + it('manipulates cart information correctly', () => { + const mockCart: any = { + product: [{ product: '1', quantity: 2 }], + }; + const mockProducts: ProductType[] = [ + { + id: '1', productName: 'Product 1', productPrice: 10, productDescription: 'Description 1', productThumbnail: 'thumb1.jpg', + productCurrency: '', + productPictures: [], + reviews: [], + related: undefined + }, + { + id: '2', productName: 'Product 2', productPrice: 20, productDescription: 'Description 2', productThumbnail: 'thumb2.jpg', + productCurrency: '', + productPictures: [], + reviews: [], + related: undefined + }, + ]; + + const manipulatedCart = handleCartInfoManipulation(mockCart, mockProducts); + + expect(manipulatedCart).toHaveLength(1); + expect(manipulatedCart[0].name).toEqual('Product 1'); + expect(manipulatedCart[0].unitPrice).toEqual(10); + expect(manipulatedCart[0].description).toEqual('Description 1'); + expect(manipulatedCart[0].thumbnail).toEqual('thumb1.jpg'); +  }); +}); \ No newline at end of file diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx index 09bcad4..474d593 100644 --- a/src/app/products/[id]/page.tsx +++ b/src/app/products/[id]/page.tsx @@ -230,4 +230,4 @@ function Page() { ); } -export default Page; +export default Page; \ No newline at end of file diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 9441974..ba14ff6 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -1,46 +1,112 @@ -'use client'; -import React from 'react'; +'use client' +import React, { useState, useEffect, Suspense } from 'react'; +import { AiOutlineSearch } from 'react-icons/ai'; import Side from '../../components/Side'; import Header from '@/components/Header'; import Footer from '@/components/Footer'; import ProductList from '@/components/ProductList'; -import { checkIsAdmin } from '@/components/isAdmin'; +import { useSearchParams } from 'next/navigation'; +import request from '@/utils/axios'; +function SuspenseWrapper() { + const searchParams = useSearchParams(); + const [Value, setValue] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [searched, setSearched] = useState(false); // New state for tracking if a search has been performed -function Page() { - // const isAdmin = checkIsAdmin(); - - // if (isAdmin) { - // console.log('User is an admin!'); - // } else { - // console.log('User is not an admin.'); - // } + useEffect(() => { + const fetchSearchResults = async () => { + const queryParams = new URLSearchParams(searchParams.toString()); + if (searchQuery) { + queryParams.set('query', searchQuery); + } + try { + const response: any = await request.get(`/search?${queryParams.toString()}`); + setSearchResults(response); + setSearched(true); + } catch (error) { + console.error('Error fetching search results:', error); + } + }; + + fetchSearchResults(); + }, [searchParams, searchQuery]); + + const handleSearch = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + console.log("Search query:", Value); + + try { + let queryParams: any = {}; + const trimmedValue = Value.trim(); + const numericValue = parseFloat(trimmedValue); + + if (!isNaN(numericValue)) { + queryParams.minPrice = numericValue; + } else if (!isNaN(numericValue)) { + queryParams.maxPrice = numericValue; + } else if (!isNaN(numericValue)) { + queryParams.minPrice = numericValue; + queryParams.maxPrice = numericValue; + } else if (trimmedValue.length > 0) { + queryParams.name = Value; + } else if (trimmedValue.length > 0) { + queryParams.category = Value; + } + + const queryString = new URLSearchParams(queryParams).toString(); + console.log(queryString); + const url = `/search?${new URLSearchParams(queryParams).toString()}`; + const response: any = await request.get(url); + + setSearchResults(response); + setSearched(true); + } catch (error) { + console.error('Error fetching search results:', error); + } + }; return ( <> -
- -
-
-

All Products

-
- +
+
+
+

All Products

+
+
+
+ setValue(e.target.value)} + /> + +
+
-
+
- +
+ {searched && searchResults.length === 0 ? ( +

No product found

+ ) : ( + + )} +
- ); } -export default Page; +export default function Page() { + return ( + Loading...
}> + + + ); +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index e48a4ef..48cab15 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -5,13 +5,12 @@ import ReactStars from 'react-rating-stars-component'; import { FaRegHeart } from 'react-icons/fa6'; import { Cards } from '../types/Product'; import image from '../../public/product.png'; +import defaultProductImage from '../../public/product-default.png'; import Link from 'next/link'; import { averageReviews } from '@/utils/averageReviews'; import { RootState, useAppDispatch, useAppSelector } from '@/redux/store'; import { handleUserAddCart } from '@/redux/slices/userCartSlice'; -// import { useRouter } from 'next/router'; - function Card({ productName, productDescription, @@ -20,35 +19,36 @@ function Card({ id, reviews, }: Cards) { - const productId = id; + const productId = id; const dispatch = useAppDispatch(); const handleNewItem = () => { dispatch(handleUserAddCart({ productPrice, productId })); + } + const handleImageError = (event: React.SyntheticEvent) => { + event.currentTarget.src = defaultProductImage.src; }; return ( -
-
- (e.currentTarget.src = '/product.png')} - alt="default image" - className="w-full h-[150px] text-[12px] object-cover" - /> -
-
-
- {productName.length < 30 - ? productName - : productName.substring(0, 30) + '...'} -
-
- {productDescription.length < 50 - ? productDescription - : productDescription.substring(0, 50) + '...'} +
+ +
+ {productName}
-
- + +
+
+ {productName.length < 30 ? productName : `${productName.substring(0, 30)}...`} +
+

+ {productDescription.length < 50 ? productDescription : `${productDescription.substring(0, 50)}...`} +

+
+ {productPrice} RWF @@ -62,21 +62,17 @@ function Card({ />
-
-
- - - - - { - handleNewItem(); - }} - /> +
+ + + + + +
); } export default Card; + diff --git a/src/components/ProductList.tsx b/src/components/ProductList.tsx index c4a1511..301ee4d 100644 --- a/src/components/ProductList.tsx +++ b/src/components/ProductList.tsx @@ -9,11 +9,13 @@ import { unstable_noStore as noStore } from 'next/cache'; import { SkeletonProduct } from '@/components/Skeleton'; import { storeAllProduct } from '@/redux/slices/userCartSlice'; import { useAppDispatch, useAppSelector } from '../redux/store'; +import defaultProductImage from '../../public/product-default.png'; interface ProdductProps { activeNav?: String; + searchResults: ProductObj[]; } -const ProductList: React.FC = ({ activeNav }) => { +const ProductList: React.FC = ({ activeNav,searchResults }) => { const [activeButton, setActiveButton] = useState(1); const fetchUrl = (nav: string) => { @@ -88,7 +90,7 @@ const ProductList: React.FC = ({ activeNav }) => { setActiveButton((prev) => (prev === 1 ? prev : prev - 1)); }; if (isLoading) { - return
{/* */}
; + return
; } if (error) { @@ -97,16 +99,18 @@ const ProductList: React.FC = ({ activeNav }) => {
; } - if (!data) { + if (!data && !searchResults.length) { return
No products found.
; } + const productsToDisplay = searchResults.length ? searchResults : data; + console.log(searchResults) return ( -
+
{data && ( <>
    - {data.map( + {productsToDisplay.map( ( product: { id: string; @@ -122,7 +126,7 @@ const ProductList: React.FC = ({ activeNav }) => { key={i.toString()} id={product.id} productPrice={product.productPrice} - productThumbnail={product.productThumbnail} + productThumbnail={product.productThumbnail || defaultProductImage.src} productDescription={product.productDescription} productName={product.productName} reviews={product.reviews} diff --git a/src/components/Side.tsx b/src/components/Side.tsx index b6b86cf..455acd9 100644 --- a/src/components/Side.tsx +++ b/src/components/Side.tsx @@ -1,45 +1,129 @@ -import React from 'react' -import Image from 'next/image' -import sideImage from "../../public/Rectangle 1403.png" +'use client'; +import React, { useState, useEffect } from 'react'; +import Image from 'next/image'; +import sideImage from "../../public/Rectangle 1403.png"; +import request from '@/utils/axios'; +import { CategoryResponse, ProductObj } from '@/types/Product'; +import { useRouter } from 'next/navigation'; function Side() { -return ( - <> -
    -
    -

    Filter Product

    -
    -

    Categories

    -

    Foods

    -

    Property

    -

    Shoes

    -

    Price

    -
    -
    -
    - RWF - 2350 -
    - -
    -
    -

    Size

    -
    -

    small

    -

    large

    + const [categories, setCategories] = useState([]); + const [products, setProducts] = useState([]); + const [minPrice, setMinPrice] = useState(''); + const [maxPrice, setMaxPrice] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(''); + const [selectedProduct, setSelectedProduct] = useState(''); + + const router = useRouter(); + + useEffect(() => { + async function fetchCategories() { + try { + const allCategories: any = await request.get('/categories'); + setCategories(allCategories.categories || []); + } catch (error) { + console.error('Error fetching categories:', error); + } + } + + async function fetchProducts() { + try { + const allProducts: any = await request.get(`/products`); + setProducts(allProducts.products || []); + } catch (error) { + console.error('Error fetching products:', error); + } + } + + fetchCategories(); + fetchProducts(); + }, []); + + useEffect(() => { + handleSearchParams(); + }, [minPrice, maxPrice, selectedCategory, selectedProduct]); + + const handleSearchParams = () => { + if (minPrice || maxPrice || selectedCategory || selectedProduct) { + const queryParams: { [key: string]: string } = {}; + if (minPrice) queryParams.minPrice = minPrice; + if (maxPrice) queryParams.maxPrice = maxPrice; + if (selectedCategory) queryParams.category = selectedCategory; + if (selectedProduct) queryParams.name = selectedProduct; + + const queryString = new URLSearchParams(queryParams).toString(); + console.log('Query Params:', queryString); // Debugging log + router.push(`/products?${queryString}`); + } + }; + + const handleCategoryClick = (category: string) => { + setSelectedCategory(category); + setSelectedProduct(''); + }; + + const handleProductClick = (product: string) => { + setSelectedProduct(product); + setSelectedCategory(''); + }; + + const handleMinPriceChange = (e: React.ChangeEvent) => { + setMinPrice(e.target.value); + }; + + const handleMaxPriceChange = (e: React.ChangeEvent) => { + setMaxPrice(e.target.value); + }; + + return ( +
    +
    +

    Filter Product

    +
    +

    Categories

    + {categories.length > 0 ? ( +
    + {categories.map((category: any) => ( +

    handleCategoryClick(category.id)} + > + {category.categoryName} +

    + ))} +
    + ) : ( +

    No categories available

    + )} +

    Price

    -
    -

    x-large

    -

    xx-large

    +
    +
    + + +
    -
    - {'side_Image'}/ + side_Image
    - -) + ); } -export default Side \ No newline at end of file +export default Side; diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index d1752ba..9533a40 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -66,29 +66,14 @@ export const SideSkeleton = () => { return ( <>
    -
    -

    -
    +

    -
    -

    -

    -

    -
    -
    -
    -
    + +

    -
    - - -
    -
    - - -
    +
    diff --git a/src/components/allProducts.tsx b/src/components/allProducts.tsx index 8acc560..f46edfc 100644 --- a/src/components/allProducts.tsx +++ b/src/components/allProducts.tsx @@ -1,15 +1,23 @@ 'use client'; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import ProductList from './ProductList'; +import { AiOutlineSearch } from 'react-icons/ai'; +import { useRouter } from 'next/navigation'; +import request from '@/utils/axios'; + export const ProductWithFilter = () => { const [Value, setValue] = useState(''); const [locarstorage, SetLocal] = useState(null); const [activeButton, setActiveButton] = useState('All'); + const [searchResults, setSearchResults] = useState([]); + const [searched, setSearched] = useState(false); // New state for tracking if a search has been performed + const router = useRouter(); const Options = [ { laber: 'All', value: 1 }, { laber: 'Rating', value: 2 }, { laber: 'wishes', value: 3 }, ]; + useEffect(() => { if (typeof window !== 'undefined') { const storedToken: string | any = localStorage.getItem('profile'); @@ -17,17 +25,56 @@ export const ProductWithFilter = () => { SetLocal(ifuser?.User?.Role.name as any); } }, []); + const handleSelect = (event: React.ChangeEvent) => { setValue(event.target.value); setActiveButton(event.target.value); }; + const handleButtonClick = (buttonName: string) => { setActiveButton(buttonName); }; + const buttonClass = (buttonName: string) => `px-4 py-2 ${ activeButton === buttonName ? 'bg-black text-white' : '' } focus:outline-none `; + + const handleSearch = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + console.log("Search query:", Value); + + try { + let queryParams: any = {}; + const trimmedValue = Value.trim(); + const numericValue = parseFloat(trimmedValue); + + if (!isNaN(numericValue)) { + queryParams.minPrice = numericValue; + } else if (!isNaN(numericValue)) { + queryParams.maxPrice = numericValue; + } else if (!isNaN(numericValue)) { + queryParams.minPrice = numericValue; + queryParams.maxPrice = numericValue; + } else if (trimmedValue.length > 0) { + queryParams.name = Value; + } else if (trimmedValue.length > 0) { + queryParams.category = Value; + } + + const queryString = new URLSearchParams(queryParams).toString(); + console.log(queryString); + + const url = `/search?${new URLSearchParams(queryParams).toString()}`; + const response: any = await request.get(url); + + setSearchResults(response); + setSearched(true); // Set searched to true when a search is performed + } catch (error) { + console.error('Error fetching search results:', error); + } + }; + return (
    @@ -90,20 +137,28 @@ export const ProductWithFilter = () => { )} {/* Search */} -
    - -
    - +
    +
    + setValue(e.target.value)} + /> +
    -
    +
    {/* Product Section */} -
    - +
    + {searched && searchResults.length === 0 ? ( +

    No product found

    + ) : ( + + )}
    diff --git a/src/types/Product.ts b/src/types/Product.ts index ff75c36..c254515 100644 --- a/src/types/Product.ts +++ b/src/types/Product.ts @@ -7,14 +7,14 @@ export interface Seller { phone?: string; } export interface ReviewType { - buyerId:string; + buyerId: string; rating: number; feedback: string; - userProfile:{ - firstName:string; - lastName:string; - profileImage:string; - } + userProfile: { + firstName: string; + lastName: string; + profileImage: string; + }; } export interface imageType { imgId: string; @@ -34,35 +34,46 @@ export interface ProductType { productDescription: string; productDiscount?: number; productName: string; - productPictures: imageType[] + productPictures: imageType[]; productPrice: number; productThumbnail: string; reviews: ReviewType[]; related: any; seller?: Seller; - sellerId?: string; + sellerId?: string; stockLevel?: number; updatedAt?: number; - } - - export interface ProductObj { - currentPage: number; - products: ProductType[]; - totalItems: number; - toatalPages: number; - } +} + +export interface ProductObj { + currentPage: number; + products: ProductType[]; + totalItems: number; + toatalPages: number; +} + +export interface Cards { + key: string; + id: string; + productPrice: number; + productThumbnail: string; + productDescription: string; + reviews: ReviewType[]; + productName: string; +} +export interface TableType { + product: string; + index: number; + id: string; +} +export interface CategoryType { + id: string; + categoryName: string; + createdAt: string; + updatedAt: string; +} - export interface Cards { - key: string; - id: string; - productPrice: number; - productThumbnail: string; - productDescription: string; - reviews:ReviewType[]; - productName:string; - } - export interface TableType { - product: string; - index: number; - id:string; - } +export interface CategoryResponse { + message: string; + categories: CategoryType[]; +}