From 97609ad4e523b3ce022910cda01842ba412f2d9a Mon Sep 17 00:00:00 2001 From: jkarenzi Date: Thu, 18 Jul 2024 11:38:21 +0200 Subject: [PATCH] feat(wishlist): implement buyer wishlist - implement wishlist UI components [Delivers #96] --- package-lock.json | 8 +- package.json | 4 +- src/__test__/home/ProductGridFour.test.tsx | 4 +- src/__test__/home/productCard.test.tsx | 27 ++- src/__test__/home/productList.test.tsx | 4 +- src/__test__/wishlist.test.tsx | 186 +++++++++++++++++++++ src/components/Navbar.tsx | 8 +- src/components/WishlistCard.tsx | 114 +++++++++++++ src/components/home/ProductCard.tsx | 9 +- src/features/Products/ProductSlice.ts | 124 +++++++++++++- src/pages/Wishlist.tsx | 35 ++++ src/routes/AppRoutes.tsx | 2 + tailwind.config.js | 1 + 13 files changed, 508 insertions(+), 18 deletions(-) create mode 100644 src/__test__/wishlist.test.tsx create mode 100644 src/components/WishlistCard.tsx create mode 100644 src/pages/Wishlist.tsx diff --git a/package-lock.json b/package-lock.json index 59ea4566..41e9a9ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6487,12 +6487,12 @@ } }, "node_modules/husky": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.0.tgz", - "integrity": "sha512-8XCjbomYTGdNF2h50dio3T3zghmZ9f/ZNzr99YwSkvDdhEjJGs5qzy8tbFx+SG8yCx2wn9nMVfZxVrr/yT8gNQ==", + "version": "9.0.11", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", + "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==", "dev": true, "bin": { - "husky": "bin.js" + "husky": "bin.mjs" }, "engines": { "node": ">=18" diff --git a/package.json b/package.json index 3d187efe..4b2b9faa 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "react-loading-skeleton": "^3.4.0", "react-redux": "^9.1.2", "react-router-dom": "^6.23.1", - "react-slider": "^2.0.6", "react-simple-maps": "^3.0.0", + "react-slider": "^2.0.6", "react-spinners": "^0.14.1", "react-toastify": "^10.0.5", "redux": "^5.0.1", @@ -60,8 +60,8 @@ "@types/node": "^20.14.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.2.22", - "@types/react-slider": "^1.3.6", "@types/react-simple-maps": "^3.0.4", + "@types/react-slider": "^1.3.6", "@types/redux-mock-store": "^1.0.6", "@types/testing-library__react": "^10.2.0", "@types/webfontloader": "^1.6.38", diff --git a/src/__test__/home/ProductGridFour.test.tsx b/src/__test__/home/ProductGridFour.test.tsx index 5d5f9b77..2482b3f9 100644 --- a/src/__test__/home/ProductGridFour.test.tsx +++ b/src/__test__/home/ProductGridFour.test.tsx @@ -171,12 +171,12 @@ describe('ProductGridFour Component', () => { mockProducts.slice(0, 4).forEach((product) => { expect( screen.getByText( - `${product.name.substring(0, 17)}${product.name.length > 18 ? '...' : ''}` + `${product.name.substring(0, 17)}${product.name.length > 17 ? '...' : ''}` ) ).toBeInTheDocument(); expect( screen.getAllByText( - `${product.shortDesc.substring(0, 27)}${product.shortDesc.length > 28 ? '...' : ''}` + `${product.shortDesc.substring(0, 27)}${product.shortDesc.length > 27 ? '...' : ''}` )[0] ).toBeInTheDocument(); expect(screen.getByAltText(product.name)).toBeInTheDocument(); diff --git a/src/__test__/home/productCard.test.tsx b/src/__test__/home/productCard.test.tsx index fdd75807..34d9cff5 100644 --- a/src/__test__/home/productCard.test.tsx +++ b/src/__test__/home/productCard.test.tsx @@ -1,9 +1,13 @@ import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { describe, it, expect } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; import ProductCard from '@/components/home/ProductCard'; import { Product } from '@/types/Product'; import User from '@/types/User'; +import signInReducer from '@/features/Auth/SignInSlice'; // Mock Product Data const mockProduct: Product = { @@ -35,17 +39,34 @@ const mockProduct: Product = { } as User, }; +const renderWithProviders = ( + ui: React.ReactElement, + { + store = configureStore({ + reducer: { + signIn: signInReducer, + }, + }), + } = {} +) => { + return render( + + {ui} + + ); +}; + describe('ProductCard Component', () => { it('renders the ProductCard component with product details', () => { - render(); + renderWithProviders(); const productName = screen.getByText( - `${mockProduct.name.substring(0, 17)}${mockProduct.name.length > 18 ? '...' : ''}` + `${mockProduct.name.substring(0, 17)}${mockProduct.name.length > 17 ? '...' : ''}` ); expect(productName).toBeInTheDocument(); const productDesc = screen.getByText( - `${mockProduct.shortDesc.substring(0, 27)}${mockProduct.shortDesc.length > 28 ? '...' : ''}` + `${mockProduct.shortDesc.substring(0, 27)}${mockProduct.shortDesc.length > 27 ? '...' : ''}` ); expect(productDesc).toBeInTheDocument(); diff --git a/src/__test__/home/productList.test.tsx b/src/__test__/home/productList.test.tsx index 71ab6356..ac14d423 100644 --- a/src/__test__/home/productList.test.tsx +++ b/src/__test__/home/productList.test.tsx @@ -89,12 +89,12 @@ describe('ProductsList Component', () => { mockProducts.forEach((product) => { const productName = screen.getByText( - `${product.name.substring(0, 17)}${product.name.length > 18 ? '...' : ''}` + `${product.name.substring(0, 17)}${product.name.length > 17 ? '...' : ''}` ); expect(productName).toBeInTheDocument(); const productDesc = screen.getByText( - `${product.shortDesc.substring(0, 27)}${product.shortDesc.length > 28 ? '...' : ''}` + `${product.shortDesc.substring(0, 27)}${product.shortDesc.length > 27 ? '...' : ''}` ); expect(productDesc).toBeInTheDocument(); diff --git a/src/__test__/wishlist.test.tsx b/src/__test__/wishlist.test.tsx new file mode 100644 index 00000000..164d28fa --- /dev/null +++ b/src/__test__/wishlist.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { describe, it, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import { MemoryRouter } from 'react-router-dom'; +import productReducer, { + fetchWishlistProducts, + addToWishlist, + removeFromWishlist, +} from '@/features/Products/ProductSlice'; +import categoryReducer from '@/features/Products/categorySlice'; +import signInReducer from '@/features/Auth/SignInSlice'; +import WishlistCard from '@/components/WishlistCard'; +import Wishlist from '@/pages/Wishlist'; +import { AppDispatch, RootState } from '@/app/store'; +import { Product } from '@/types/Product'; + +const mock = new MockAdapter(axios); + +const renderWithProviders = ( + ui: React.ReactElement, + { + store = configureStore({ + reducer: { + products: productReducer, + categories: categoryReducer, + signIn: signInReducer, + }, + }), + } = {} +) => { + return render( + + {ui} + + ); +}; + +const product = { + id: 1, + name: 'Product name', + shortDesc: 'Short description', + salesPrice: 230, + regularPrice: 280, + averageRating: 4.7, + image: 'path_to_image', + gallery: ['path_to_image', 'path_to_image'], + category: { + id: 1, + name: 'Category name', + description: 'Category description', + }, + longDesc: 'Long description', + quantity: 10, + tags: ['tag1', 'tag2'], + type: 'Simple', + isAvailable: true, + isFeatured: true, + reviews: [], + vendor: { + id: 1, + firstName: 'Vendor', + lastName: 'Name', + email: 'example@gmail.com', + password: 'hashed_password', + userType: { + id: 1, + name: 'Vendor', + permissions: ['manage_products', 'view_orders'], + }, + orders: [], + googleId: '', + facebookId: '', + picture: 'path_to_picture', + provider: 'local', + isVerified: true, + status: 'active', + }, +} as Product; + +describe('Wishlist async actions', () => { + let store: ReturnType; + + beforeEach(() => { + store = configureStore({ + reducer: { + products: productReducer, + }, + }); + mock.reset(); + }); + + it('fetches wishlist products', async () => { + const products = [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + ]; + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/getOneWishlist`) + .reply(200, { + data: { product: products }, + }); + + const { dispatch }: { dispatch: AppDispatch } = store; + await dispatch(fetchWishlistProducts('token')); + + const state = (store.getState() as RootState).products; + expect(state.wishlistProducts).toEqual(products); + }); + + it('adds a product to the wishlist', async () => { + const products = [{ id: 1, name: 'Product 1' }]; + mock + .onPost(`${import.meta.env.VITE_BASE_URL}/buyer/addItemToWishlist`) + .reply(200, { + data: { product: products }, + }); + + const { dispatch }: { dispatch: AppDispatch } = store; + await dispatch(addToWishlist({ id: 1, token: 'token' })); + + const state = (store.getState() as RootState).products; + expect(state.wishlistProducts).toEqual(products); + }); + + it('removes a product from the wishlist', async () => { + mock + .onDelete(`${import.meta.env.VITE_BASE_URL}/buyer/removeToWishlist`) + .reply(200, { + data: { product: [] }, + }); + + const { dispatch }: { dispatch: AppDispatch } = store; + await dispatch(removeFromWishlist({ id: 1, token: 'token' })); + + const state = (store.getState() as RootState).products; + expect(state.wishlistProducts).toEqual([]); + }); +}); + +describe('WishlistCard', () => { + it('renders the wishlist card', () => { + renderWithProviders(); + + expect(screen.getByText(/Product name/i)).toBeInTheDocument(); + expect(screen.getByText(/In Stock/i)).toBeInTheDocument(); + expect(screen.getByText(/\$500/i)).toBeInTheDocument(); + expect(screen.getByText(/\$700/i)).toBeInTheDocument(); + }); +}); + +describe('Wishlist Page', () => { + it('renders wishlist products', async () => { + const products = [product]; + + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/getOneWishlist`) + .reply(200, { + data: { product: products }, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/My Wishlist/i)).toBeInTheDocument(); + expect(screen.getByText(/Product name/i)).toBeInTheDocument(); + }); + }); + + it('shows no products message when wishlist is empty', () => { + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/getOneWishlist`) + .reply(200, { + data: { product: [] }, + }); + + renderWithProviders(); + + expect( + screen.getByText(/You currently have no products in your wishlist/i) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 03ebaa99..d5151fee 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -68,7 +68,13 @@ function Navbar() { 5 - + navigate('/wishlist')} + /> {user ? (
diff --git a/src/components/WishlistCard.tsx b/src/components/WishlistCard.tsx new file mode 100644 index 00000000..dbdf448d --- /dev/null +++ b/src/components/WishlistCard.tsx @@ -0,0 +1,114 @@ +import { IoClose } from 'react-icons/io5'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import Button from './form/Button'; +import { Product } from '@/types/Product'; +import { removeFromWishlist } from '@/features/Products/ProductSlice'; + +function WishlistCard({ product }: { product: Product }) { + const { token } = useAppSelector((state) => state.signIn); + const dispatch = useAppDispatch(); + return ( +
+ dispatch(removeFromWishlist({ token, id: product.id }))} + /> +
+ wishlistImage +
+
+

+ {product.quantity > 0 ? 'In Stock' : 'Out of Stock'} +

+

+ {product.name.slice(0, 20)} + {product.name.length > 20 ? '...' : ''} +

+
+
+ {product.averageRating} + {Array.from({ length: Math.floor(product.averageRating) }).map( + (_, index) => { + return ( +
+ + + +
+ ); + } + )} +
+ + + + + + + + + +
+
+ {Array.from({ length: Math.floor(4 - product.averageRating) }).map( + (_, index) => { + return ( +
+ + + +
+ ); + } + )} +
+
+ ${500} + ${700} +
+
+
+ ); +} + +export default WishlistCard; diff --git a/src/components/home/ProductCard.tsx b/src/components/home/ProductCard.tsx index a6841df7..d03cafca 100644 --- a/src/components/home/ProductCard.tsx +++ b/src/components/home/ProductCard.tsx @@ -1,3 +1,5 @@ +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { addToWishlist } from '@/features/Products/ProductSlice'; import { Product } from '@/types/Product'; interface ProductCardProps { @@ -5,6 +7,8 @@ interface ProductCardProps { } function ProductCard({ product }: ProductCardProps) { + const dispatch = useAppDispatch(); + const { token } = useAppSelector((state) => state.signIn); return (

{product.name.substring(0, 17)} - {product.name.length > 18 && '...'} + {product.name.length > 17 && '...'}

dispatch(addToWishlist({ token, id: product.id }))} xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-gray-600 cursor-pointer bg-gray-100 p-1" viewBox="0 0 24 24" @@ -44,7 +49,7 @@ function ProductCard({ product }: ProductCardProps) {

{product.shortDesc.substring(0, 27)} - {product.shortDesc.length > 28 && '...'} + {product.shortDesc.length > 27 && '...'}

diff --git a/src/features/Products/ProductSlice.ts b/src/features/Products/ProductSlice.ts index 41302714..0424cff8 100644 --- a/src/features/Products/ProductSlice.ts +++ b/src/features/Products/ProductSlice.ts @@ -1,10 +1,10 @@ // In your counterSlice.ts or a similar file import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { Product } from '@/types/Product'; import User from '@/types/User'; import { RootState } from '../../app/store'; -import { showErrorToast } from '@/utils/ToastConfig'; +import { showErrorToast, showSuccessToast } from '@/utils/ToastConfig'; interface Payload { message: string; @@ -76,12 +76,95 @@ export const searchProducts = createAsyncThunk< } ); +export const fetchWishlistProducts = createAsyncThunk( + 'products/fetchWishlistProducts', + async (token, thunkAPI) => { + try { + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/buyer/getOneWishlist`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + return response.data.data.product; + } catch (err) { + const error = err as AxiosError; + if (error.response) { + return thunkAPI.rejectWithValue( + (error.response.data as { message: string }).message + ); + } + return thunkAPI.rejectWithValue('An error occured'); + } + } +); + +export const addToWishlist = createAsyncThunk< + Product[], + { id: number; token: string | null } +>('products/addToWishlist', async ({ id, token }, thunkAPI) => { + try { + const response = await axios.post( + `${import.meta.env.VITE_BASE_URL}/buyer/addItemToWishlist`, + { productId: id }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return response.data.data.product; + } catch (err) { + const error = err as AxiosError; + if (error.response) { + return thunkAPI.rejectWithValue( + (error.response.data as { message: string }).message + ); + } + return thunkAPI.rejectWithValue('An error occured'); + } +}); + +export const removeFromWishlist = createAsyncThunk< + Product[], + { id: number; token: string | null } +>('products/removeFromWishlist', async ({ id, token }, thunkAPI) => { + try { + const response = await axios.delete( + `${import.meta.env.VITE_BASE_URL}/buyer/removeToWishlist`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + data: { + productId: id, + }, + } + ); + + return response.data.data.product; + } catch (err) { + const error = err as AxiosError; + if (error.response) { + return thunkAPI.rejectWithValue( + (error.response.data as { message: string }).message + ); + } + return thunkAPI.rejectWithValue('An error occured'); + } +}); + interface ProductsState { isLoading: boolean; products: Product[]; allProducts: Product[]; recommendedProducts: Product[]; total: number; + wishlistProducts: Product[]; + wishlistLoading: boolean; } const initialState: ProductsState = { @@ -119,6 +202,8 @@ const initialState: ProductsState = { ], allProducts: [], recommendedProducts: [], + wishlistProducts: [], + wishlistLoading: false, total: 0, }; @@ -186,6 +271,41 @@ const productsSlice = createSlice({ }) .addCase(fetchRecommendedProducts.rejected, (_, action) => { showErrorToast(action.payload as string); + }) + .addCase(fetchWishlistProducts.pending, (state) => { + state.wishlistLoading = true; + }) + .addCase(fetchWishlistProducts.fulfilled, (state, action) => { + state.wishlistLoading = false; + state.wishlistProducts = action.payload; + }) + .addCase(fetchWishlistProducts.rejected, (state, action) => { + state.wishlistLoading = false; + showErrorToast(action.payload as string); + }) + .addCase(addToWishlist.pending, (state) => { + state.wishlistLoading = true; + }) + .addCase(addToWishlist.fulfilled, (state, action) => { + state.wishlistLoading = false; + showSuccessToast('Product succesfully added to wishlist'); + state.wishlistProducts = action.payload; + }) + .addCase(addToWishlist.rejected, (state, action) => { + state.wishlistLoading = false; + showErrorToast(action.payload as string); + }) + .addCase(removeFromWishlist.pending, (state) => { + state.wishlistLoading = true; + }) + .addCase(removeFromWishlist.fulfilled, (state, action) => { + state.wishlistLoading = false; + showSuccessToast('Product successfully removed from wishlist'); + state.wishlistProducts = action.payload; + }) + .addCase(removeFromWishlist.rejected, (state, action) => { + state.wishlistLoading = false; + showErrorToast(action.payload as string); }); }, }); diff --git a/src/pages/Wishlist.tsx b/src/pages/Wishlist.tsx new file mode 100644 index 00000000..20dd204c --- /dev/null +++ b/src/pages/Wishlist.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import WishlistCard from '@/components/WishlistCard'; +import { fetchWishlistProducts } from '@/features/Products/ProductSlice'; + +function Wishlist() { + const dispatch = useAppDispatch(); + const { wishlistProducts } = useAppSelector((state) => state.products); + const { token } = useAppSelector((state) => state.signIn); + + useEffect(() => { + dispatch(fetchWishlistProducts(token)); + }, [dispatch, token]); + + return ( +
+

+ My Wishlist{' '} + ({wishlistProducts?.length} items) +

+
+ {wishlistProducts.length === 0 && ( +
+ You currently have no products in your wishlist +
+ )} + {wishlistProducts.map((product) => ( + + ))} +
+
+ ); +} + +export default Wishlist; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 013d18d0..fb04114b 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -11,6 +11,7 @@ import ResetPasswordForm from '@/components/password/PasswordResetForm'; import AdminRoutes from '@/pages/AdminRoutes'; import Admin from '@/pages/Admin'; import Shop from '@/pages/Shop'; +import Wishlist from '@/pages/Wishlist'; function AppRoutes() { return ( @@ -18,6 +19,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> } /> diff --git a/tailwind.config.js b/tailwind.config.js index 55a26228..c232a1c4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -40,6 +40,7 @@ export default { dashbordblue: '#4079ED', linkGrey: '#9095A1', sliderBg: '#F0F9FF', + wishlistBg: '#F5F1F1', }, fontFamily: { Lexend: ['Lexend'],