Skip to content

Commit

Permalink
feat(wishlist): implement buyer wishlist
Browse files Browse the repository at this point in the history
- implement wishlist UI components

[Delivers #96]
  • Loading branch information
jkarenzi committed Jul 18, 2024
1 parent bcb61cb commit 42d23f6
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 2 deletions.
8 changes: 7 additions & 1 deletion src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ function Navbar() {
5
</div>
</div>
<FiHeart color="#424856" size="20" title="wishlist" />
<FiHeart
color="#424856"
size="20"
title="wishlist"
className="cursor-pointer"
onClick={() => navigate('/wishlist')}
/>
</div>
{user ? (
<div className="xs:hidden lg:flex items-center gap-2">
Expand Down
114 changes: 114 additions & 0 deletions src/components/WishlistCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import Button from './form/Button';
import { Product } from '@/types/Product';
import { IoClose } from 'react-icons/io5';
import { removeFromWishlist } from '@/features/Products/ProductSlice';

const WishlistCard = ({ product }: { product: Product }) => {
const { token } = useAppSelector((state) => state.signIn);
const dispatch = useAppDispatch();
return (
<div className="relative flex bg-wishlistBg xs:w-full md:w-[30rem] h-60 p-2 pb-4">
<IoClose
size={20}
className="absolute top-2 right-2 cursor-pointer"
onClick={() => dispatch(removeFromWishlist({ token, id: product.id }))}
/>
<div className="flex w-2/5">
<img
src={product.image}
alt="wishlistImage"
className="w-full object-cover"
/>
</div>
<div className="flex flex-col items-start gap-4 w-3/5 pl-2">
<h1 className="text-grey font-semibold">
{product.quantity > 0 ? 'In Stock' : 'Out of Stock'}
</h1>
<h2 className="text-grey font-normal">
{product.name.slice(0, 20)}
{product.name.length > 20 ? '...' : ''}
</h2>
<div className="flex items-center font-medium gap-2 relative w-fit">
<div className="flex items-center font-medium gap-2 relative w-fit">
<span className="">{product.averageRating}</span>
{Array.from({ length: Math.floor(product.averageRating) }).map(
(_, index) => {
return (
<div data-testid="ratingStar" key={index}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-yellow-400"
viewBox="0 0 36 36"
>
<path
fill="currentColor"
d="M27.287 34.627c-.404 0-.806-.124-1.152-.371L18 28.422l-8.135 5.834a1.97 1.97 0 0 1-2.312-.008a1.971 1.971 0 0 1-.721-2.194l3.034-9.792l-8.062-5.681a1.98 1.98 0 0 1-.708-2.203a1.978 1.978 0 0 1 1.866-1.363L12.947 13l3.179-9.549a1.976 1.976 0 0 1 3.749 0L23 13l10.036.015a1.975 1.975 0 0 1 1.159 3.566l-8.062 5.681l3.034 9.792a1.97 1.97 0 0 1-.72 2.194a1.957 1.957 0 0 1-1.16.379"
/>
</svg>
</div>
);
}
)}
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
viewBox="0 0 36 36"
data-testid="halfStar"
>
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop
offset={`${(product.averageRating - Math.floor(product.averageRating)) * 100}%`}
style={{
stopColor: 'rgb(250 204 21)',
stopOpacity: 1,
}}
/>
<stop
offset={`${(product.averageRating - Math.floor(product.averageRating)) * 100}%`}
style={{
stopColor: 'rgb(156 163 175)',
stopOpacity: 1,
}}
/>
</linearGradient>
</defs>
<path
fill="url(#grad1)"
d="M27.287 34.627c-.404 0-.806-.124-1.152-.371L18 28.422l-8.135 5.834a1.97 1.97 0 0 1-2.312-.008a1.971 1.971 0 0 1-.721-2.194l3.034-9.792l-8.062-5.681a1.98 1.98 0 0 1-.708-2.203a1.978 1.978 0 0 1 1.866-1.363L12.947 13l3.179-9.549a1.976 1.976 0 0 1 3.749 0L23 13l10.036.015a1.975 1.975 0 0 1 1.159 3.566l-8.062 5.681l3.034 9.792a1.97 1.97 0 0 1-.72 2.194a1.957 1.957 0 0 1-1.16.379"
/>
</svg>
</div>
</div>
{Array.from({ length: Math.floor(4 - product.averageRating) }).map(
(_, index) => {
return (
<div data-testid="emptyStar" key={index}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-gray-400"
viewBox="0 0 36 36"
>
<path
fill="currentColor"
d="M27.287 34.627c-.404 0-.806-.124-1.152-.371L18 28.422l-8.135 5.834a1.97 1.97 0 0 1-2.312-.008a1.971 1.971 0 0 1-.721-2.194l3.034-9.792l-8.062-5.681a1.98 1.98 0 0 1-.708-2.203a1.978 1.978 0 0 1 1.866-1.363L12.947 13l3.179-9.549a1.976 1.976 0 0 1 3.749 0L23 13l10.036.015a1.975 1.975 0 0 1 1.159 3.566l-8.062 5.681l3.034 9.792a1.97 1.97 0 0 1-.72 2.194a1.957 1.957 0 0 1-1.16.379"
/>
</svg>
</div>
);
}
)}
</div>
<div className="flex gap-4 items-center">
<span className="text-red-700 font-bold text-lg">${500}</span>
<span className="line-through text-gray-500">${700}</span>
</div>
<Button title="Add to Cart" />
</div>
</div>
);
};

export default WishlistCard;
6 changes: 6 additions & 0 deletions src/components/home/ProductCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { addToWishlist } from '@/features/Products/ProductSlice';
import { Product } from '@/types/Product';
import { useEffect } from 'react';

interface ProductCardProps {
product: Product;
}

function ProductCard({ product }: ProductCardProps) {
const dispatch = useAppDispatch();

Check failure on line 11 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

src/__test__/home/ProductGridFour.test.tsx > ProductGridFour Component > renders ProductGridFour with up to 4 products

Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider> ❯ useReduxContext node_modules/react-redux/src/hooks/useReduxContext.ts:17:13 ❯ useStore node_modules/react-redux/src/hooks/useStore.ts:97:23 ❯ Module.useDispatch2 node_modules/react-redux/src/hooks/useDispatch.ts:72:19 ❯ ProductCard src/components/home/ProductCard.tsx:11:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5

Check failure on line 11 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

src/__test__/home/ProductGridFour.test.tsx > ProductGridFour Component > renders the correct product details

Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider> ❯ useReduxContext node_modules/react-redux/src/hooks/useReduxContext.ts:17:13 ❯ useStore node_modules/react-redux/src/hooks/useStore.ts:97:23 ❯ Module.useDispatch2 node_modules/react-redux/src/hooks/useDispatch.ts:72:19 ❯ ProductCard src/components/home/ProductCard.tsx:11:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5

Check failure on line 11 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

src/__test__/home/productCard.test.tsx > ProductCard Component > renders the ProductCard component with product details

Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider> ❯ useReduxContext node_modules/react-redux/src/hooks/useReduxContext.ts:17:13 ❯ useStore node_modules/react-redux/src/hooks/useStore.ts:97:23 ❯ Module.useDispatch2 node_modules/react-redux/src/hooks/useDispatch.ts:72:19 ❯ ProductCard src/components/home/ProductCard.tsx:11:20 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5
const { token } = useAppSelector((state) => state.signIn);

Check failure on line 12 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

Unhandled error

TypeError: Cannot destructure property 'token' of 'useAppSelector(...)' as it is undefined. ❯ ProductCard src/components/home/ProductCard.tsx:12:11 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7 ❯ recoverFromConcurrentError node_modules/react-dom/cjs/react-dom.development.js:25889:20 ❯ performSyncWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:26135:20 This error originated in "src/__test__/shop.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "fetches and displays products". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 12 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

Unhandled error

TypeError: Cannot destructure property 'token' of 'useAppSelector(...)' as it is undefined. ❯ ProductCard src/components/home/ProductCard.tsx:12:11 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7 ❯ recoverFromConcurrentError node_modules/react-dom/cjs/react-dom.development.js:25889:20 ❯ performSyncWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:26135:20 This error originated in "src/__test__/shop.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "filters products based on user input". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.

Check failure on line 12 in src/components/home/ProductCard.tsx

View workflow job for this annotation

GitHub Actions / build (20.x)

src/__test__/home/productList.test.tsx > ProductsList Component > renders the ProductsList component with products

TypeError: Cannot destructure property 'token' of 'useAppSelector(...)' as it is undefined. ❯ ProductCard src/components/home/ProductCard.tsx:12:11 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork node_modules/react-dom/cjs/react-dom.development.js:21626:16 ❯ beginWork$1 node_modules/react-dom/cjs/react-dom.development.js:27465:14 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom.development.js:26599:12 ❯ workLoopSync node_modules/react-dom/cjs/react-dom.development.js:26505:5 ❯ renderRootSync node_modules/react-dom/cjs/react-dom.development.js:26473:7 ❯ recoverFromConcurrentError node_modules/react-dom/cjs/react-dom.development.js:25889:20 ❯ performConcurrentWorkOnRoot node_modules/react-dom/cjs/react-dom.development.js:25789:22
return (
<div className="shadow-lg rounded-lg relative">
<img
Expand Down Expand Up @@ -32,6 +37,7 @@ function ProductCard({ product }: ProductCardProps) {
{product.name.length > 18 && '...'}
</h3>
<svg
onClick={() => 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"
Expand Down
104 changes: 103 additions & 1 deletion src/features/Products/ProductSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import axios 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;
Expand Down Expand Up @@ -76,12 +76,77 @@ export const searchProducts = createAsyncThunk<
}
);

export const fetchWishlistProducts = createAsyncThunk<Product[], string | null>(
'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) {
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) {
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) {
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 = {
Expand Down Expand Up @@ -119,6 +184,8 @@ const initialState: ProductsState = {
],
allProducts: [],
recommendedProducts: [],
wishlistProducts: [],
wishlistLoading: false,
total: 0,
};

Expand Down Expand Up @@ -186,6 +253,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);
});
},
});
Expand Down
35 changes: 35 additions & 0 deletions src/pages/Wishlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import WishlistCard from '@/components/WishlistCard';
import { fetchWishlistProducts } from '@/features/Products/ProductSlice';
import { useEffect } from 'react';

const Wishlist = () => {
const dispatch = useAppDispatch();
const { wishlistProducts } = useAppSelector((state) => state.products);
const { token } = useAppSelector((state) => state.signIn);

useEffect(() => {
dispatch(fetchWishlistProducts(token));
}, []);

return (
<div className="flex flex-col w-full min-h-40 p-8 gap-8">
<h1 className="text-2xl font-semibold">
My Wishlist{' '}
<span className="text-grey">({wishlistProducts?.length} items)</span>{' '}
</h1>
<div className="w-full flex flex-wrap gap-4">
{wishlistProducts.length === 0 && (
<div className="text-grey font-medium">
You currently have no products in your wishlist
</div>
)}
{wishlistProducts.map((product) => (
<WishlistCard product={product} />
))}
</div>
</div>
);
};

export default Wishlist;
2 changes: 2 additions & 0 deletions src/routes/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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 (
<Routes>
<Route element={<HomeLayout />}>
<Route index path="/" element={<Home />} />
<Route path="shop" element={<Shop />} />
<Route path="wishlist" element={<Wishlist />} />
</Route>
<Route path="/signup" element={<SignUp />} />
<Route path="/signIn" element={<SignIn />} />
Expand Down
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default {
dashbordblue: '#4079ED',
linkGrey: '#9095A1',
sliderBg: '#F0F9FF',
wishlistBg: '#F5F1F1',
},
fontFamily: {
Lexend: ['Lexend'],
Expand Down

0 comments on commit 42d23f6

Please sign in to comment.