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)}
- - ${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}
- + dispatch(addCartItem({ productId: product.id, quantity: 1 })) + } > - - + {' '} + + + + 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" }] }