diff --git a/src/__test__/categoriesSection.test.tsx b/src/__test__/categoriesSection.test.tsx index 83db18af..c795fd44 100644 --- a/src/__test__/categoriesSection.test.tsx +++ b/src/__test__/categoriesSection.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router'; import CategoriesHome from '@/components/home/CategoriesHome'; import { store } from '@/app/store'; @@ -8,7 +9,9 @@ describe('App', () => { it('Renders Home Categories Section', () => { render( - + + + ); expect(screen.getByText('New Arrivals')).toBeInTheDocument(); diff --git a/src/__test__/home/categories.test.tsx b/src/__test__/home/categories.test.tsx index 81a6041f..7ada3981 100644 --- a/src/__test__/home/categories.test.tsx +++ b/src/__test__/home/categories.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom'; import { describe, it, expect, beforeEach } from 'vitest'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; +import { MemoryRouter } from 'react-router'; import Categories from '@/components/home/sidebar'; const mockStore = configureStore([]); @@ -32,7 +33,9 @@ describe('Categories Component', () => { it('renders Categories component with category details', () => { render( - + + + ); diff --git a/src/__test__/home/productList.test.tsx b/src/__test__/home/productList.test.tsx index 70becc75..7764024f 100644 --- a/src/__test__/home/productList.test.tsx +++ b/src/__test__/home/productList.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom'; import { describe, it, expect, beforeEach } from 'vitest'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; +import { MemoryRouter } from 'react-router'; import ProductsList from '@/components/home/productList'; import { Product } from '@/types/Product'; import User from '@/types/User'; @@ -86,7 +87,9 @@ describe('ProductsList Component', () => { it('renders the ProductsList component with products', () => { render( - + + + ); diff --git a/src/__test__/productDetails.test.tsx b/src/__test__/productDetails.test.tsx new file mode 100644 index 00000000..f60ae28f --- /dev/null +++ b/src/__test__/productDetails.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Store, configureStore } from '@reduxjs/toolkit'; +import { waitFor } from '@testing-library/dom'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import productsReducer, { + fetchProductDetails, +} from '@/features/Products/ProductSlice'; +import signInReducer from '@/features/Auth/SignInSlice'; +import { AppDispatch, RootState } from '@/app/store'; +import ProductDetails from '@/pages/ProductDetails'; +import bestSellingReducer from '@/features/Popular/bestSellingProductSlice'; + +const mockProduct = { + id: 1, + name: 'Mock Product', + image: '/images/mock-product.jpg', + rating: 4.5, + salesPrice: 99.99, + regularPrice: 149.99, + totalQtySold: 25, + longDesc: 'This is a mock product used for testing purposes.', + shortDesc: 'This is a short description', + category: 'Electronics', + similarProducts: [], + reviews: [ + { + id: 1, + user: { + id: 1, + firstName: 'new', + lastName: 'user', + picture: 'http://fake.png', + }, + rating: 5, + content: 'excellent product', + }, + ], + gallery: [], + tags: ['testTag'], + vendor: { + name: 'Tester', + email: 'testervendor@gmail.com', + picture: 'https://fake.png', + }, +}; + +const renderWithProviders = ( + ui: React.ReactElement, + { + store = configureStore({ + reducer: { + products: productsReducer, + bestSellingProducts: bestSellingReducer, + signIn: signInReducer, + }, + }), + } = {} +) => { + return render( + + + + + + + + ); +}; + +describe('ProductDetails Page', () => { + let mock: MockAdapter; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + it('renders the ProductDetails page correctly', () => { + mock.onGet(`${import.meta.env.VITE_BASE_URL}/product/1`).reply(500); + + renderWithProviders(); + + expect(screen.getByText(/Product Details/i)).toBeInTheDocument(); + }); + + it('fetches and displays product details', async () => { + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`) + .reply(200, { + product: mockProduct, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getAllByText(/Mock Product/i)[0]).toBeInTheDocument(); + expect(screen.getByText(/testTag/i)).toBeInTheDocument(); + expect(screen.getByText(/\$99.99/i)).toBeInTheDocument(); + expect(screen.getByText(/\$149.99/i)).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('25')).toBeInTheDocument(); + expect( + screen.getByText(/This is a mock product used for testing purposes./i) + ).toBeInTheDocument(); + expect( + screen.getByText(/This is a short description/i) + ).toBeInTheDocument(); + }); + }); + + it('shows error message on failed fetch', async () => { + mock.onGet(`${import.meta.env.VITE_BASE_URL}/product/1`).reply(500, { + message: 'Internal Server Error', + }); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByText(/Failed to load product details/i) + ).toBeInTheDocument(); + }); + }); +}); + +describe('Product Details async action', () => { + let store: Store; + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + store = configureStore({ + reducer: { + products: productsReducer, + }, + }); + }); + + it('should handle fetchProductDetails.fulfilled', async () => { + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`) + .reply(200, { + product: mockProduct, + }); + + const { dispatch }: { dispatch: AppDispatch } = store; + await dispatch(fetchProductDetails(1)); + const state = (store.getState() as RootState).products; + expect(state.productDetailsLoading).toBe(false); + expect(state.productDetails).toEqual(mockProduct); + }); + + it('should handle fetchProductDetails.rejected', async () => { + mock + .onGet(`${import.meta.env.VITE_BASE_URL}/buyer/get_product/1`) + .reply(500); + + const { dispatch }: { dispatch: AppDispatch } = store; + await dispatch(fetchProductDetails(1)); + const state = (store.getState() as RootState).products; + expect(state.productDetailsLoading).toBe(false); + expect(state.productDetails).toBeNull(); + }); +}); diff --git a/src/__test__/wishlist.test.tsx b/src/__test__/wishlist.test.tsx index 164d28fa..367747d4 100644 --- a/src/__test__/wishlist.test.tsx +++ b/src/__test__/wishlist.test.tsx @@ -147,8 +147,8 @@ describe('WishlistCard', () => { 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(); + expect(screen.getByText(/\$230/i)).toBeInTheDocument(); + expect(screen.getByText(/\$280/i)).toBeInTheDocument(); }); }); diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..c22d4b1b --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; +import { Navigate } from 'react-router'; +import { useAppSelector } from '@/app/hooks'; + +interface ProtectedRouteProps { + children: ReactNode; + roles: string[]; +} + +function ProtectedRoute({ children, roles }: ProtectedRouteProps) { + const user = useAppSelector((state) => state.signIn.user); + + if (user && user.userType && roles.includes(user.userType.name)) { + return children; + } + + return ; +} + +export default ProtectedRoute; diff --git a/src/components/WishlistCard.tsx b/src/components/WishlistCard.tsx index dbdf448d..d80ee3cd 100644 --- a/src/components/WishlistCard.tsx +++ b/src/components/WishlistCard.tsx @@ -102,8 +102,12 @@ function WishlistCard({ product }: { product: Product }) { )}
- ${500} - ${700} + + ${product.salesPrice} + + + ${product.regularPrice} +
dispatch(addToWishlist({ token, id: product.id }))} xmlns="http://www.w3.org/2000/svg" diff --git a/src/features/Products/ProductSlice.ts b/src/features/Products/ProductSlice.ts index 0424cff8..652b7e4f 100644 --- a/src/features/Products/ProductSlice.ts +++ b/src/features/Products/ProductSlice.ts @@ -11,6 +11,11 @@ interface Payload { data: Product[]; } +interface IProduct extends Product { + similarProducts: Product[]; + totalQtySold: number; +} + interface SearchParams { keyword?: string; category?: number[]; @@ -33,11 +38,30 @@ export const fetchProducts = createAsyncThunk( export const fetchRecommendedProducts = createAsyncThunk( 'products/fetchRecommendedProducts', - async () => { - const response = await axios.get( - `${import.meta.env.VITE_BASE_URL}/product/recommended` - ); - return response.data.data; + async (_, thunkAPI) => { + try { + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/product/recommended` + ); + return response.data.data; + } catch (err) { + return thunkAPI.rejectWithValue('An error occured'); + } + } +); + +export const fetchProductDetails = createAsyncThunk( + 'products/fetchProductDetails', + async (id, thunkAPI) => { + try { + const response = await axios.get( + `${import.meta.env.VITE_BASE_URL}/buyer/get_product/${id}` + ); + + return response.data.product; + } catch (err) { + return thunkAPI.rejectWithValue('An error occured'); + } } ); @@ -165,6 +189,8 @@ interface ProductsState { total: number; wishlistProducts: Product[]; wishlistLoading: boolean; + productDetailsLoading: boolean; + productDetails: IProduct | null; } const initialState: ProductsState = { @@ -204,6 +230,8 @@ const initialState: ProductsState = { recommendedProducts: [], wishlistProducts: [], wishlistLoading: false, + productDetails: null, + productDetailsLoading: false, total: 0, }; @@ -306,6 +334,17 @@ const productsSlice = createSlice({ .addCase(removeFromWishlist.rejected, (state, action) => { state.wishlistLoading = false; showErrorToast(action.payload as string); + }) + .addCase(fetchProductDetails.pending, (state) => { + state.productDetailsLoading = true; + }) + .addCase(fetchProductDetails.fulfilled, (state, action) => { + state.productDetailsLoading = false; + state.productDetails = action.payload; + }) + .addCase(fetchProductDetails.rejected, (state, action) => { + state.productDetailsLoading = false; + showErrorToast(action.payload as string); }); }, }); diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx new file mode 100644 index 00000000..a97d1464 --- /dev/null +++ b/src/pages/ProductDetails.tsx @@ -0,0 +1,702 @@ +import axios, { AxiosError } from 'axios'; +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { FaStar } from 'react-icons/fa'; +import ClipLoader from 'react-spinners/ClipLoader'; +import { IoClose } from 'react-icons/io5'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import Button from '@/components/form/Button'; +import { + addToWishlist, + fetchProductDetails, +} from '@/features/Products/ProductSlice'; +import { Product } from '@/types/Product'; +import { showErrorToast, showSuccessToast } from '@/utils/ToastConfig'; +import { fetchBestSellingProducts } from '@/features/Popular/bestSellingProductSlice'; + +interface IProduct extends Product { + similarProducts: Product[]; + totalQtySold: number; +} + +function SimilarProductCard({ product }: { product: Product }) { + const navigate = useNavigate(); + return ( +
+ + prodImg +
+ +
+
+
+ {product.averageRating} + {Array.from({ length: Math.floor(product.averageRating) }).map( + (_, index) => { + return ( +
+ + + +
+ ); + } + )} +
+ + + + + + + + + +
+
+ {Array.from({ length: Math.floor(4 - product.averageRating) }).map( + (_, index) => { + return ( +
+ + + +
+ ); + } + )} +
+

${product.salesPrice}

+
+
+
+ ); +} + +function ProductDetails() { + const product = useAppSelector((state) => state.products.productDetails); + const { productDetailsLoading } = useAppSelector((state) => state.products); + const bestSellers = useAppSelector( + (state) => state.bestSellingProducts.bestSellingProduct + ); + const { token } = useAppSelector((state) => state.signIn); + const dispatch = useAppDispatch(); + const { id } = useParams(); + const [newReview, setNewReview] = useState(false); + const [review, setReview] = useState<{ + rating: number | null; + content: string; + productId: number | undefined; + }>({ rating: null, content: '', productId: product?.id }); + const [error, setError] = useState(''); + const [reviewLoading, setReviewLoading] = useState(false); + const [toggleLoginOverlay, setToggleLoginOverlay] = useState(false); + const [isVisible, setIsVisible] = useState({ state: true, name: 'details' }); + + useEffect(() => { + if (id) { + dispatch(fetchProductDetails(parseInt(id, 10))); + } + }, [dispatch, newReview, id]); + + useEffect(() => { + dispatch(fetchBestSellingProducts()); + }, [dispatch]); + + const isBestSeller = (prod: IProduct, bestSellerProds: Product[]) => { + return bestSellerProds.some( + (bestSellerProd) => bestSellerProd.id === prod.id + ); + }; + + const submitReview = async () => { + if (!token) { + setToggleLoginOverlay(true); + return; + } + + if (!review.rating) { + setError('Rating is required'); + return; + } + + if (!review.content) { + setError('Content is required'); + return; + } + + setReviewLoading(true); + + try { + const response = await axios.post( + `${import.meta.env.VITE_BASE_URL}/review`, + review, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + setNewReview(true); + setError(''); + setReviewLoading(false); + showSuccessToast(response.data.message as string); + } catch (err) { + const errorObj = err as AxiosError; + setError(''); + setReviewLoading(false); + if (!errorObj.response) { + showErrorToast('An error occured'); + return; + } + showErrorToast((errorObj.response.data as { message: string }).message); + } + }; + + return ( +
+ {!productDetailsLoading && !product && ( +
+ Failed to load product details +
+ )} + {productDetailsLoading && ( +
+ +

Just a sec! We are almost there

+
+ )} + {toggleLoginOverlay && ( +
+
+
+ Error +
+ setToggleLoginOverlay(false)} + /> +
+

+ Only logged in users are allowed to submit product reviews. + Please login or create an account if you do not have one +

+
+
+
+ )} +

+ + Shop + {' '} + > {(!productDetailsLoading && product?.name) || 'Product Details'} +

+ {!productDetailsLoading && product && ( +
+
+
+ prodImg +
+
+ {product.gallery.map((image) => ( +
+ galleryImg +
+ ))} +
+
+
+
+

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

+ {isBestSeller(product, bestSellers) && ( + + )} +
+
+
+
+ {product.averageRating} + {Array.from({ + length: Math.floor(product.averageRating), + }).map((_, index) => { + return ( +
+ + + +
+ ); + })} +
+ + + + + + + + + +
+
+ {Array.from({ + length: Math.floor(4 - product.averageRating), + }).map((_, index) => { + return ( +
+ + + +
+ ); + })} +
+
+
+

+ + {product.reviews.length} + {' '} + Reviews +

+

+ + {product.totalQtySold} + {' '} + Products sold +

+
+
+ + ${product.salesPrice} + + + ${product.regularPrice} + +
+
+

Promotion

+
+ {Math.round( + (product.regularPrice - product.salesPrice) / + product.regularPrice / + 0.01 + )} + % Off +
+
+
+ +
+
+ + 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" + > + + +

Add to wishlist

+
+
+ Tags: + {product.tags.map((tag, i) => ( +

+ {i !== 0 && ', '} + {tag} +

+ ))} +
+
+
+ )} + {!productDetailsLoading && product && ( +
+
+ + + +
+ {isVisible.state && isVisible.name === 'details' && ( +
+
+

Short Description

+

{product.shortDesc}

+
+
+

Long Description

+

{product.longDesc}

+
+
+ )} + {isVisible.state && isVisible.name === 'about' && ( +
+
+
+
+ vendorImg +
+
+
+

+ Vendor Name: + + {`${product.vendor.firstName} ${product.vendor.lastName}`} + +

+

+ Vendor Email: + + {product.vendor.email} + +

+

+ {`${product.vendor.firstName} ${product.vendor.lastName}`} +

+

+ {product.vendor.email} +

+
+
+
+ )} + {isVisible.state && isVisible.name === 'reviews' && ( +
+ {product?.reviews.length === 0 && ( +
No reviews found
+ )} + {product?.reviews.map((productReview) => ( +
+
+
+ profImg +
+
+

{`${productReview.user.firstName} ${productReview.user.lastName}`}

+
+
+ {productReview.rating} + {Array.from({ + length: Math.floor(productReview.rating), + }).map((_, index) => { + return ( +
+ + + +
+ ); + })} +
+ + + + + + + + + +
+
+ {Array.from({ + length: Math.floor(4 - productReview.rating), + }).map((_, index) => { + return ( +
+ + + +
+ ); + })} +
+
+
+

{productReview.content}

+
+ ))} +
+ )} +
+ )} + {!productDetailsLoading && product && ( +
+

Add a review

+

+ Your rating * +

+
+ {Array.from({ length: 5 }, (_, i) => ( + + setReview({ ...review, rating: i + 1, productId: product.id }) + } + /> + ))} +
+

+ Your review * +

+ + {error &&
{error}
} +
+ )} + {!productDetailsLoading && product && ( +
+

Similar Products

+

+ Dont miss this opportunity at a special discount just for this week. +

+
+ {product.similarProducts.map((item: Product) => { + if (item.id !== product.id) { + return ( + + ); + } + return null; + })} +
+
+ )} +
+ ); +} + +export default ProductDetails; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index f904892d..5b59ba04 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -16,6 +16,8 @@ import Shop from '@/pages/Shop'; import Wishlist from '@/pages/Wishlist'; import { Orders } from '@/components/Orders/Orders'; import AddProducts from '@/components/dashBoard/addProducts'; +import ProductDetails from '@/pages/ProductDetails'; +import ProtectedRoute from '@/components/ProtectedRoute'; function AppRoutes() { return ( @@ -23,7 +25,15 @@ function AppRoutes() { }> } /> } /> - } /> + + + + } + /> + } /> } /> } /> diff --git a/tailwind.config.js b/tailwind.config.js index c232a1c4..8789f892 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -41,6 +41,7 @@ export default { linkGrey: '#9095A1', sliderBg: '#F0F9FF', wishlistBg: '#F5F1F1', + textareaBorder: '#A8ADB7', }, fontFamily: { Lexend: ['Lexend'],