diff --git a/package-lock.json b/package-lock.json
index 264bd83d..478c51f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -77,7 +77,7 @@
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
- "husky": "^9.0.11",
+ "husky": "^9.1.1",
"jest": "^29.7.0",
"jsdom": "^24.1.0",
"lint-staged": "^15.2.5",
@@ -5946,12 +5946,12 @@
}
},
"node_modules/husky": {
- "version": "9.0.11",
- "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz",
- "integrity": "sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw==",
+ "version": "9.1.1",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.1.tgz",
+ "integrity": "sha512-fCqlqLXcBnXa/TJXmT93/A36tJsjdJkibQ1MuIiFyCCYUlpYpIaj2mv1w+3KR6Rzu1IC3slFTje5f6DUp2A2rg==",
"dev": true,
"bin": {
- "husky": "bin.mjs"
+ "husky": "bin.js"
},
"engines": {
"node": ">=18"
@@ -6191,9 +6191,9 @@
}
},
"node_modules/is-core-module": {
- "version": "2.13.1",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
- "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz",
+ "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==",
"dev": true,
"dependencies": {
"hasown": "^2.0.0"
@@ -8407,9 +8407,9 @@
}
},
"node_modules/msw/node_modules/type-fest": {
- "version": "4.21.0",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz",
- "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==",
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.22.1.tgz",
+ "integrity": "sha512-9tHNEa0Ov81YOopiVkcCJVz5TM6AEQ+CHHjFIktqPnE3NV0AHIkx+gh9tiCl58m/66wWxkOC9eltpa75J4lQPA==",
"dev": true,
"engines": {
"node": ">=16"
diff --git a/package.json b/package.json
index 7bc49495..455a9dcd 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@types/swiper": "^6.0.0",
"axios": "^1.7.2",
"axios-mock-adapter": "^1.22.0",
+ "chart.js": "^4.4.3",
"date-fns": "^3.6.0",
"chart.js": "^4.4.3",
"cloudinary": "^2.2.0",
@@ -85,7 +86,7 @@
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
- "husky": "^9.0.11",
+ "husky": "^9.1.1",
"jest": "^29.7.0",
"jsdom": "^24.1.0",
"lint-staged": "^15.2.5",
diff --git a/public/mastercard.svg b/public/mastercard.svg
new file mode 100644
index 00000000..56d537c3
--- /dev/null
+++ b/public/mastercard.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/visa.svg b/public/visa.svg
new file mode 100644
index 00000000..00681935
--- /dev/null
+++ b/public/visa.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/__test__/Cart/Cart.test.tsx b/src/__test__/Cart/Cart.test.tsx
new file mode 100644
index 00000000..ee4867af
--- /dev/null
+++ b/src/__test__/Cart/Cart.test.tsx
@@ -0,0 +1,81 @@
+import { configureStore } from '@reduxjs/toolkit';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import cartReducer, {
+ fetchCartItems,
+ addCartItem,
+ updateCartItemQuantity,
+ removeCartItem,
+} from '@/features/Cart/cartSlice';
+
+describe('cartSlice', () => {
+ let store = configureStore({ reducer: { cartItems: cartReducer } });
+ let httpMock: MockAdapter;
+
+ beforeEach(() => {
+ store = configureStore({ reducer: { cartItems: cartReducer } });
+ httpMock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ httpMock.reset();
+ });
+
+ it('should fetch cart items successfully', async () => {
+ const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 2 }];
+ httpMock
+ .onGet(`${process.env.VITE_BASE_URL}/cart`)
+ .reply(200, { cartItems: mockCartItems });
+
+ await store.dispatch(fetchCartItems());
+ const state = store.getState().cartItems;
+ expect(state.cartItems).toEqual(mockCartItems);
+ expect(state.loading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it('should handle adding a cart item', async () => {
+ const newCartItem = { id: 2, name: 'Product 2', quantity: 1 };
+ httpMock
+ .onPost(`${process.env.VITE_BASE_URL}/cart`)
+ .reply(200, { cartItem: newCartItem });
+
+ await store.dispatch(addCartItem({ productId: 2, quantity: 1 }));
+ const state = store.getState().cartItems;
+ expect(state.cartItems).toContainEqual(newCartItem);
+ expect(state.loading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it('should handle updating a cart item quantity', async () => {
+ const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 20 }];
+ httpMock
+ .onGet(`${process.env.VITE_BASE_URL}/cart`)
+ .reply(200, { cartItems: mockCartItems });
+
+ await store.dispatch(fetchCartItems());
+ const updatedCartItem = { id: 1, quantity: 3 };
+ httpMock.onPatch(`${process.env.VITE_BASE_URL}/cart/1`).reply(200);
+
+ await store.dispatch(updateCartItemQuantity({ itemId: 1, quantity: 3 }));
+ const state = store.getState().cartItems;
+ expect(state.cartItems.find((item) => item.id === 1)?.quantity).toBe(
+ updatedCartItem.quantity
+ );
+ expect(state.loading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+
+ it('should handle removing a cart item', async () => {
+ const itemIdToRemove = 1;
+ httpMock.onDelete(`${process.env.VITE_BASE_URL}/cart/1`).reply(200);
+
+ await store.dispatch(removeCartItem(itemIdToRemove));
+ const state = store.getState().cartItems;
+ expect(
+ state.cartItems.find((item) => item.id === itemIdToRemove)
+ ).toBeUndefined();
+ expect(state.loading).toBe(false);
+ expect(state.error).toBeNull();
+ });
+});
diff --git a/src/app/store.ts b/src/app/store.ts
index e52abd69..40a8883f 100644
--- a/src/app/store.ts
+++ b/src/app/store.ts
@@ -15,6 +15,7 @@ import {
} from '@/features/Auth/password';
import buyerSlice from '@/app/Dashboard/buyerSlice';
import orderSlice from './Dashboard/orderSlice';
+import cartReducer from '@/features/Cart/cartSlice';
import ordersSliceReducer from '@/features/Orders/ordersSlice';
@@ -35,6 +36,7 @@ export const store = configureStore({
orders: ordersSliceReducer,
product: addProductSlice,
DeshboardProducts: dashboardProductsSlice,
+ cartItems: cartReducer,
},
});
diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx
index 6ce38e0e..c3074f3e 100644
--- a/src/components/Cart/Cart.tsx
+++ b/src/components/Cart/Cart.tsx
@@ -1,16 +1,47 @@
+import { useEffect, useState } from 'react';
import CartItem from './CartItem';
import HSButton from '../form/HSButton';
+import ProductCard from '../home/ProductCard';
+import { RootState } from '@/app/store';
+import { Product } from '@/types/Product';
+import {
+ selectProducts,
+ fetchProducts,
+} from '@/features/Products/ProductSlice';
+import { useAppDispatch, useAppSelector } from '@/app/hooks';
+import { selectCartItems, fetchCartItems } from '@/features/Cart/cartSlice';
export default function Cart() {
+ const products: Product[] = useAppSelector((state: RootState) =>
+ selectProducts(state)
+ );
+ const cartItems = useAppSelector((state: RootState) =>
+ selectCartItems(state)
+ );
+ const total = cartItems.reduce(
+ (acc, item) => acc + item.product.salesPrice * item.quantity,
+ 0
+ );
+ const [viewAll, setViewAll] = useState(false);
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchProducts());
+ dispatch(fetchCartItems());
+ }, [dispatch]);
return (
-
-
+
+
Products in cart
- 3 products
-
-
-
-
-
+ {viewAll &&
+ cartItems.map((item) => (
+
+ ))}
+ {!viewAll &&
+ cartItems
+ .slice(0, 3)
+ .map((item) => (
+
+ ))}
+
Total:
- $20088
+ ${total}
+
+
Recommended Products
+
+ {products.slice(0, 4).map((product) => (
+
+ ))}
+
+
);
}
diff --git a/src/components/Cart/CartItem.tsx b/src/components/Cart/CartItem.tsx
index 05b016dd..a6deef70 100644
--- a/src/components/Cart/CartItem.tsx
+++ b/src/components/Cart/CartItem.tsx
@@ -1,16 +1,29 @@
import { useState } from 'react';
+import {
+ updateCartItemQuantity,
+ removeCartItem,
+} from '@/features/Cart/cartSlice';
+import { useAppDispatch } from '@/app/hooks';
interface CartProps {
+ id: number;
price: number;
name: string;
+ quantity: number;
}
-function CartItem({ price, name }: CartProps) {
- const [quantity, setQuantity] = useState(0);
+function CartItem({ id, price, name, quantity }: CartProps) {
+ const dispatch = useAppDispatch();
const [size, setSize] = useState<'M' | 'S' | 'L'>('M');
const handleQuantityChange = (amount: number) => {
- setQuantity((prevQuantity) => Math.max(0, prevQuantity + amount));
+ if (quantity + amount < 1) {
+ dispatch(removeCartItem(id));
+ } else {
+ dispatch(
+ updateCartItemQuantity({ itemId: id, quantity: amount + quantity })
+ );
+ }
};
const handleSize = (newSize: 'M' | 'S' | 'L') => {
@@ -82,7 +95,7 @@ function CartItem({ price, name }: CartProps) {
>
-
-
{quantity}
+
{Math.round(quantity)}
handleQuantityChange(1)}
@@ -95,10 +108,16 @@ function CartItem({ price, name }: CartProps) {
-
+ dispatch(removeCartItem(id))}
+ >
Remove
- ${price * quantity}
+
+ ${Math.round(price * quantity)}
+
);
diff --git a/src/components/home/ProductCard.tsx b/src/components/home/ProductCard.tsx
index 0a3ca4c2..207560b8 100644
--- a/src/components/home/ProductCard.tsx
+++ b/src/components/home/ProductCard.tsx
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { addToWishlist } from '@/features/Products/ProductSlice';
import { Product } from '@/types/Product';
+import { addCartItem } from '@/features/Cart/cartSlice';
interface ProductCardProps {
product: Product;
@@ -139,18 +140,26 @@ function ProductCard({ product }: ProductCardProps) {
${product.regularPrice}
-
+ {' '}
+
+
diff --git a/src/features/Cart/cartSlice.tsx b/src/features/Cart/cartSlice.tsx
new file mode 100644
index 00000000..19ca20a2
--- /dev/null
+++ b/src/features/Cart/cartSlice.tsx
@@ -0,0 +1,155 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
+import axios from 'axios';
+import Cart from '@/interfaces/cart';
+import { RootState } from '../../app/store';
+
+interface CartState {
+ cartItems: Cart[];
+ loading: boolean;
+ error: string | null;
+}
+
+const initialState: CartState = {
+ cartItems: [],
+ loading: false,
+ error: null,
+};
+
+const baseUrl = import.meta.env.VITE_BASE_URL;
+
+interface Payload {
+ cartItems: Cart[];
+}
+
+interface AddPayload {
+ cartItem: Cart;
+}
+
+export const fetchCartItems = createAsyncThunk(
+ 'cart/fetchCartItems',
+ async () => {
+ const tokenFromStorage = localStorage.getItem('token') || '';
+ const response = await axios.get(`${baseUrl}/cart`, {
+ headers: {
+ Authorization: `Bearer ${tokenFromStorage}`,
+ },
+ });
+ return response.data.cartItems;
+ }
+);
+
+export const updateCartItemQuantity = createAsyncThunk(
+ 'cart/updateCartItemQuantity',
+ async ({ itemId, quantity }: { itemId: number; quantity: number }) => {
+ const tokenFromStorage = localStorage.getItem('token') || '';
+ await axios.patch(
+ `${baseUrl}/cart/${itemId}`,
+ { quantity },
+ {
+ headers: {
+ Authorization: `Bearer ${tokenFromStorage}`,
+ },
+ }
+ );
+ return { itemId, quantity };
+ }
+);
+
+export const removeCartItem = createAsyncThunk(
+ 'cart/removeCartItem',
+ async (itemId: number) => {
+ const tokenFromStorage = localStorage.getItem('token') || '';
+ await axios.delete(`${baseUrl}/cart/${itemId}`, {
+ headers: {
+ Authorization: `Bearer ${tokenFromStorage}`,
+ },
+ });
+ return itemId;
+ }
+);
+
+export const addCartItem = createAsyncThunk(
+ 'cart/addCartItem',
+ async ({ productId, quantity }: { productId: number; quantity: number }) => {
+ const tokenFromStorage = localStorage.getItem('token') || '';
+ const response = await axios.post(
+ `${baseUrl}/cart`,
+ { productId, quantity },
+ {
+ headers: {
+ Authorization: `Bearer ${tokenFromStorage}`,
+ },
+ }
+ );
+ return response.data.cartItem;
+ }
+);
+
+const cartSlice = createSlice({
+ name: 'cartItems',
+ initialState,
+ reducers: {},
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchCartItems.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(fetchCartItems.fulfilled, (state, action) => {
+ state.loading = false;
+ state.cartItems = action.payload;
+ })
+ .addCase(fetchCartItems.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.error.message || 'Failed to fetch cart items';
+ })
+ .addCase(addCartItem.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(addCartItem.fulfilled, (state, action) => {
+ state.loading = false;
+ state.cartItems = [...state.cartItems, action.payload];
+ })
+ .addCase(addCartItem.rejected, (state, action) => {
+ state.loading = false;
+ state.error =
+ action.error.message || 'Failed to update cart item quantity';
+ })
+ .addCase(updateCartItemQuantity.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(updateCartItemQuantity.fulfilled, (state, action) => {
+ const index = state.cartItems.findIndex(
+ (item) => item.id === action.payload.itemId
+ );
+ const update = state.cartItems[index] as Cart;
+ update.quantity = action.payload.quantity;
+ state.loading = false;
+ state.cartItems = state.cartItems.splice(index, 1, update);
+ })
+ .addCase(updateCartItemQuantity.rejected, (state, action) => {
+ state.loading = false;
+ state.error =
+ action.error.message || 'Failed to update cart item quantity';
+ })
+ .addCase(removeCartItem.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(removeCartItem.fulfilled, (state, action) => {
+ state.loading = false;
+ state.cartItems = state.cartItems.filter(
+ (item) => item.id !== action.payload
+ );
+ })
+ .addCase(removeCartItem.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.error.message || 'Failed to remove cart item';
+ });
+ },
+});
+
+export const selectCartItems = (state: RootState) => state.cartItems.cartItems;
+export default cartSlice.reducer;
diff --git a/src/interfaces/cart.ts b/src/interfaces/cart.ts
new file mode 100644
index 00000000..ac70cf9d
--- /dev/null
+++ b/src/interfaces/cart.ts
@@ -0,0 +1,19 @@
+interface Cart {
+ id: number;
+ quantity: number;
+ createdAt: string;
+ updatedAt: string;
+ user: {
+ id: number;
+ };
+ product: {
+ id: number;
+ name: string;
+ image: string;
+ quantity: number;
+ regularPrice: number;
+ salesPrice: number;
+ };
+}
+
+export default Cart;
diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx
index 5b59ba04..7eac97ef 100644
--- a/src/routes/AppRoutes.tsx
+++ b/src/routes/AppRoutes.tsx
@@ -18,6 +18,7 @@ import { Orders } from '@/components/Orders/Orders';
import AddProducts from '@/components/dashBoard/addProducts';
import ProductDetails from '@/pages/ProductDetails';
import ProtectedRoute from '@/components/ProtectedRoute';
+import Cart from '@/components/Cart/Cart';
function AppRoutes() {
return (
@@ -34,6 +35,8 @@ function AppRoutes() {
}
/>
} />
+ } />
+ } />
} />
} />
diff --git a/tsconfig.json b/tsconfig.json
index a8b922bd..cce82ee2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": [".eslintrc.cjs", "src", "dist/Cart"],
+ "include": [".eslintrc.cjs", "src", "dist/Cart", "dist/Checkout"],
"references": [{ "path": "./tsconfig.node.json" }]
}