From 76fd804853053ba9a581fd496df2521744bfb400 Mon Sep 17 00:00:00 2001 From: niyobern Date: Sun, 21 Jul 2024 03:23:48 +0200 Subject: [PATCH] Created Checkout page --- package.json | 2 + public/icons/address.svg | 1 + public/icons/card.svg | 1 + public/icons/ccv.svg | 1 + public/icons/date.svg | 1 + public/icons/location.svg | 1 + public/icons/user.svg | 1 + public/momo.svg | 125 ++++++ public/unionpay.svg | 1 + src/__test__/Checkout/cardInput.test.tsx | 60 +++ src/__test__/Checkout/checkout.test.tsx | 143 +++++++ src/__test__/Orders/Orders.test.tsx | 10 +- src/__test__/navbar.test.tsx | 38 +- src/app/store.ts | 2 + src/components/Cart/Cart.tsx | 2 +- src/components/Checkout/CardInput.tsx | 236 ++++++++++ src/components/Checkout/Checkout.tsx | 403 ++++++++++++++++++ src/components/Checkout/header.tsx | 60 +++ src/components/Navbar.tsx | 17 +- src/components/Orders/OrderDetailsModal.tsx | 6 +- src/components/Orders/Orders.tsx | 6 +- src/components/dashBoard/DashboardSideNav.tsx | 2 +- src/features/Checkout/checkoutSlice.tsx | 179 ++++++++ src/features/Orders/ordersSlice.ts | 28 +- src/interfaces/checkout.ts | 73 ++++ src/interfaces/order.ts | 13 +- src/pages/Checkout.tsx | 11 + src/routes/AppRoutes.tsx | 4 +- tsconfig.json | 2 +- 29 files changed, 1393 insertions(+), 36 deletions(-) create mode 100644 public/icons/address.svg create mode 100644 public/icons/card.svg create mode 100644 public/icons/ccv.svg create mode 100644 public/icons/date.svg create mode 100644 public/icons/location.svg create mode 100644 public/icons/user.svg create mode 100644 public/momo.svg create mode 100644 public/unionpay.svg create mode 100644 src/__test__/Checkout/cardInput.test.tsx create mode 100644 src/__test__/Checkout/checkout.test.tsx create mode 100644 src/components/Checkout/CardInput.tsx create mode 100644 src/components/Checkout/Checkout.tsx create mode 100644 src/components/Checkout/header.tsx create mode 100644 src/features/Checkout/checkoutSlice.tsx create mode 100644 src/interfaces/checkout.ts create mode 100644 src/pages/Checkout.tsx diff --git a/package.json b/package.json index de7f8b7a..e993c1bd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "axios": "^1.7.2", "axios-mock-adapter": "^1.22.0", "chart.js": "^4.4.3", + "chart.js": "^4.4.3", + "date-fns": "^3.6.0", "cloudinary": "^2.2.0", "cloudinary-core": "^2.13.1", "date-fns": "^3.6.0", diff --git a/public/icons/address.svg b/public/icons/address.svg new file mode 100644 index 00000000..34e1c338 --- /dev/null +++ b/public/icons/address.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/card.svg b/public/icons/card.svg new file mode 100644 index 00000000..914aacc7 --- /dev/null +++ b/public/icons/card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/ccv.svg b/public/icons/ccv.svg new file mode 100644 index 00000000..9a20e59a --- /dev/null +++ b/public/icons/ccv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/date.svg b/public/icons/date.svg new file mode 100644 index 00000000..5a1bee21 --- /dev/null +++ b/public/icons/date.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/location.svg b/public/icons/location.svg new file mode 100644 index 00000000..31b1bc22 --- /dev/null +++ b/public/icons/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/user.svg b/public/icons/user.svg new file mode 100644 index 00000000..36a314fb --- /dev/null +++ b/public/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/momo.svg b/public/momo.svg new file mode 100644 index 00000000..cc0b0152 --- /dev/null +++ b/public/momo.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/unionpay.svg b/public/unionpay.svg new file mode 100644 index 00000000..0c1b1993 --- /dev/null +++ b/public/unionpay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/__test__/Checkout/cardInput.test.tsx b/src/__test__/Checkout/cardInput.test.tsx new file mode 100644 index 00000000..06b58614 --- /dev/null +++ b/src/__test__/Checkout/cardInput.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import CardInput from '@/components/Checkout/CardInput'; + +describe('CardInput Component', () => { + it('renders CardInput component and types into card number input', () => { + const mockSaveCard = vi.fn(); + render(); + const cardNumberInput = screen.getByPlaceholderText('Card Number'); + fireEvent.change(cardNumberInput, { + target: { value: '4111111111111111' }, + }); + expect(cardNumberInput).toHaveValue('4111111111111111'); + }); + + it('detects Visa card type correctly', () => { + const mockSaveCard = vi.fn(); + render(); + const cardNumberInput = screen.getByPlaceholderText('Card Number'); + fireEvent.change(cardNumberInput, { + target: { value: '4111111111111111' }, + }); + expect(screen.getByAltText('visa')).toBeInTheDocument(); + }); + + test('detects Mastercard card type correctly', () => { + const mockSaveCard = vi.fn(); + render(); + const cardNumberInput = screen.getByPlaceholderText('Card Number'); + fireEvent.change(cardNumberInput, { + target: { value: '5105105105105100' }, + }); + expect(screen.getByAltText('mastercard')).toBeInTheDocument(); + }); + + test('shows error for invalid card type', () => { + const mockSaveCard = vi.fn(); + render(); + const cardNumberInput = screen.getByPlaceholderText('Card Number'); + fireEvent.change(cardNumberInput, { + target: { value: '1234567890123456' }, + }); + expect(screen.getByText('Invalid Card')).toBeInTheDocument(); + }); + test('handles expiry date change correctly', () => { + const mockSaveCard = vi.fn(); + render(); + const expiryDateInput = screen.getByPlaceholderText('Expiry MM/YY'); + fireEvent.change(expiryDateInput, { target: { value: '12/34' } }); + expect(expiryDateInput).toHaveValue('12/34'); + }); + + test('shows error for invalid expiry date', () => { + const mockSaveCard = vi.fn(); + render(); + const expiryDateInput = screen.getByPlaceholderText('Expiry MM/YY'); + fireEvent.change(expiryDateInput, { target: { value: '19/11' } }); + expect(screen.getByText('Invalid Expiry Date')).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/Checkout/checkout.test.tsx b/src/__test__/Checkout/checkout.test.tsx new file mode 100644 index 00000000..26d52ab3 --- /dev/null +++ b/src/__test__/Checkout/checkout.test.tsx @@ -0,0 +1,143 @@ +import { configureStore } from '@reduxjs/toolkit'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import checkoutReducer, { + placeOrder, + getOrders, + makePayment, + updateDeliveryInfo, + updateCouponCode, +} from '@/features/Checkout/checkoutSlice'; +import Order from '@/interfaces/order'; + +// Create mock for axios +const mock = new MockAdapter(axios); + +// Configure mock store with the checkout reducer +const store = configureStore({ + reducer: { + checkout: checkoutReducer, + }, +}); + +describe('checkoutSlice', () => { + beforeEach(() => { + mock.reset(); + }); + + it('should handle initial state', () => { + expect(store.getState().checkout).toEqual({ + checkout: { + id: 31, + totalAmount: 160, + status: 'Pending', + couponCode: '', + deliveryInfo: { + address: '123 Main St', + city: 'Anytown', + zip: '12345', + }, + country: 'US', + paymentInfo: null, + trackingNumber: 'Tr280585', + createdAt: '2024-07-22T01:48:05.301Z', + updatedAt: '2024-07-22T11:01:20.291Z', + paid: true, + orderDetails: [ + { + id: 41, + quantity: 2, + price: 160, + }, + ], + }, + loading: false, + paying: false, + error: null, + }); + }); + + it('placeOrder updates state on fulfilled', async () => { + const order = { + deliveryInfo: { + address: '123 Main St', + city: 'Anytown', + zip: '12345', + }, + couponCode: 'string', + email: 'string', + firstName: 'string', + lastName: 'string', + }; + + mock.onPost('/checkout').reply(200, { order }); + + await store.dispatch(placeOrder(order)); + + expect(store.getState().checkout.checkout.deliveryInfo).toEqual( + expect.objectContaining(order.deliveryInfo) + ); + expect(store.getState().checkout.checkout.status).toEqual('Pending'); + }); + + it('getOrders updates state on fulfilled', async () => { + const orders: Order[] = [ + { + country: 'US', + couponCode: '', + createdAt: '2024-07-22T01:48:05.301Z', + deliveryInfo: { + address: '123 Main St', + city: 'Anytown', + zip: '12345', + }, + id: 31, + orderDetails: [ + { + id: 41, + price: 160, + quantity: 2, + }, + ], + paid: true, + paymentInfo: null, + status: 'Pending', + totalAmount: 160, + trackingNumber: 'Tr280585', + updatedAt: '2024-07-22T11:01:20.291Z', + }, + ]; + + mock.onGet('/checkout/getall-order').reply(200, orders); + + await store.dispatch(getOrders()); + + expect(store.getState().checkout.checkout).toEqual( + expect.objectContaining(orders[0]) + ); + }); + + it('makePayment updates state on fulfilled', async () => { + mock.onPost('/buyer/payment').reply(200, { success: true }); + + await store.dispatch(makePayment(31)); + + expect(store.getState().checkout.paying).toEqual(true); + }); + + it('updateDeliveryInfo updates delivery info', () => { + const deliveryInfo = { address: '456 Main St' }; + store.dispatch(updateDeliveryInfo(deliveryInfo)); + + expect(store.getState().checkout.checkout.deliveryInfo).toEqual( + expect.objectContaining(deliveryInfo) + ); + }); + + it('updateCouponCode updates coupon code', () => { + const couponCode = 'NEWYEAR'; + store.dispatch(updateCouponCode(couponCode)); + + expect(store.getState().checkout.checkout.couponCode).toEqual(couponCode); + }); +}); diff --git a/src/__test__/Orders/Orders.test.tsx b/src/__test__/Orders/Orders.test.tsx index 0ae08535..fe796cd5 100644 --- a/src/__test__/Orders/Orders.test.tsx +++ b/src/__test__/Orders/Orders.test.tsx @@ -13,8 +13,8 @@ const mockOrders: Order[] = [ updatedAt: '2023-07-17T00:00:00Z', status: 'Pending', totalAmount: 100, - deliveryInfo: - '{"address": "123 Main St", "city": "Anytown", "country": "USA"}', + country: 'USA', + deliveryInfo: { address: '123 Main St', city: 'Anytown', zip: '12345' }, paymentInfo: null, createdAt: '', paid: false, @@ -26,8 +26,8 @@ const mockOrders: Order[] = [ updatedAt: '2023-07-16T00:00:00Z', status: 'Completed', totalAmount: 200, - deliveryInfo: - '{"address": "456 Elm St", "city": "Othertown", "country": "USA"}', + country: 'USA', + deliveryInfo: { address: '123 Main St', city: 'Anytown', zip: '12345' }, paymentInfo: null, createdAt: '', paid: false, @@ -168,6 +168,6 @@ describe('Orders Component', () => { ); const rows = screen.getAllByRole('row'); - expect(rows.length).toBe(11); // + expect(rows.length).toBe(6); // }); }); diff --git a/src/__test__/navbar.test.tsx b/src/__test__/navbar.test.tsx index 557b535a..88f9f487 100644 --- a/src/__test__/navbar.test.tsx +++ b/src/__test__/navbar.test.tsx @@ -3,15 +3,26 @@ import { MemoryRouter } from 'react-router-dom'; import { describe, it, expect } from 'vitest'; import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; import Navbar from '@/components/Navbar'; import signInReducer from '@/features/Auth/SignInSlice'; +import cartReducer from '@/features/Cart/cartSlice'; const createTestStore = () => - configureStore({ reducer: { signIn: signInReducer } }); + configureStore({ + reducer: { signIn: signInReducer, cartItems: cartReducer }, + }); +let httpMock: MockAdapter; describe('Navbar Component', () => { it('renders Navbar component', () => { const store = createTestStore(); + httpMock = new MockAdapter(axios); + const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 2 }]; + httpMock + .onGet(`${process.env.VITE_BASE_URL}/cart`) + .reply(200, { cartItems: mockCartItems }); render( @@ -53,6 +64,11 @@ describe('Navbar Component', () => { it('highlights the correct navigation link based on the current route', () => { const store = createTestStore(); + httpMock = new MockAdapter(axios); + const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 2 }]; + httpMock + .onGet(`${process.env.VITE_BASE_URL}/cart`) + .reply(200, { cartItems: mockCartItems }); render( @@ -84,6 +100,11 @@ describe('Navbar Component', () => { it('renders links with correct paths', () => { const store = createTestStore(); + httpMock = new MockAdapter(axios); + const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 2 }]; + httpMock + .onGet(`${process.env.VITE_BASE_URL}/cart`) + .reply(200, { cartItems: mockCartItems }); render( @@ -115,13 +136,13 @@ describe('Navbar Component', () => { ); - const cartCount = screen.getByText(/5/i); + const cartCount = screen.getByText(/0/i); expect(cartCount).toBeInTheDocument(); }); it('renders profile options on avatar click', () => { const store = configureStore({ - reducer: { signIn: signInReducer }, + reducer: { signIn: signInReducer, cartItems: cartReducer }, preloadedState: { signIn: { token: 'test token', @@ -148,9 +169,18 @@ describe('Navbar Component', () => { email: null, }, }, + cartItems: { + cartItems: [], + loading: false, + error: null, + }, }, }); - + httpMock = new MockAdapter(axios); + const mockCartItems = [{ id: 1, name: 'Product 1', quantity: 2 }]; + httpMock + .onGet(`${process.env.VITE_BASE_URL}/cart`) + .reply(200, { cartItems: mockCartItems }); render( diff --git a/src/app/store.ts b/src/app/store.ts index b91ac69c..0f7f79c9 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -17,6 +17,7 @@ import { import buyerSlice from '@/app/Dashboard/buyerSlice'; import orderSlice from './Dashboard/orderSlice'; import cartReducer from '@/features/Cart/cartSlice'; +import checkoutSlice from '@/features/Checkout/checkoutSlice'; import ordersSliceReducer from '@/features/Orders/ordersSlice'; import contactReducer from '@/features/contact/contactSlice'; @@ -41,6 +42,7 @@ export const store = configureStore({ cartItems: cartReducer, allProducts: allProductSlice, contact: contactReducer, + checkout: checkoutSlice, }, }); diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx index c3074f3e..62f848d4 100644 --- a/src/components/Cart/Cart.tsx +++ b/src/components/Cart/Cart.tsx @@ -85,7 +85,7 @@ export default function Cart() {

Total:

${total} - +
diff --git a/src/components/Checkout/CardInput.tsx b/src/components/Checkout/CardInput.tsx new file mode 100644 index 00000000..6f93f386 --- /dev/null +++ b/src/components/Checkout/CardInput.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react'; + +interface Errors { + expirydate: boolean; + cardType: boolean; +} + +export interface Card { + cardNumber: string; + cardType: 'visa' | 'mastercard' | 'unionpay' | null; + cardHolder: string; + expiryDate: string; + cvv: string; +} + +interface ComponentProps { + saveCard: (newCard: Card) => void; +} + +export default function CardInput({ saveCard }: ComponentProps) { + const [card, setCard] = useState({ + cardNumber: '', + cardType: null, + cardHolder: '', + expiryDate: '', + cvv: '', + }); + const [errors, setErrors] = useState({ + expirydate: false, + cardType: false, + }); + function detectCardType(details: string) { + const re = { + unionpay: /^(62|88)\d+$/, + visa: /^4[0-9]{12}(?:[0-9]{3})?$/, + mastercard: /^5[1-5][0-9]{14}$/, + }; + const keys: ('unionpay' | 'visa' | 'mastercard')[] = [ + 'unionpay', + 'visa', + 'mastercard', + ]; + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; + if (re[key].test(details)) { + return key; + } + } + return null; + } + function handleCardChange(e: React.ChangeEvent) { + const cardType = detectCardType(e.target.value); + if (cardType === 'visa') { + setCard({ ...card, cardType: 'visa', cardNumber: e.target.value }); + setErrors({ ...errors, cardType: false }); + } else if (cardType === 'mastercard') { + setCard({ ...card, cardType: 'mastercard', cardNumber: e.target.value }); + setErrors({ ...errors, cardType: false }); + } else if (cardType === 'unionpay') { + setCard({ ...card, cardType: 'unionpay', cardNumber: e.target.value }); + setErrors({ ...errors, cardType: false }); + } else { + setCard({ ...card, cardType: null }); + setErrors({ ...errors, cardType: true }); + } + } + + function handleNameChange(e: React.ChangeEvent) { + const cardHolder = e.target.value; + setCard({ ...card, cardHolder }); + } + + function handleExpiryChange(e: React.ChangeEvent) { + const today = new Date(); + let expiryDate = e.target.value; + if (expiryDate.length === 2 && !expiryDate.includes('/')) { + expiryDate += '/'; + } + if (expiryDate.length > 5 && expiryDate.includes('/')) { + expiryDate = expiryDate.slice(0, 5); + } + if (Number(expiryDate.slice(0, 2)) > 12) { + setErrors({ ...errors, expirydate: true }); + } else if (Number(expiryDate.slice(3, 5)) > 99) { + setErrors({ ...errors, expirydate: true }); + } else if (Number(expiryDate.slice(3, 5)) < today.getFullYear() % 100) { + setErrors({ ...errors, expirydate: true }); + } else if (Number(expiryDate.slice(0, 2)) <= today.getMonth()) { + setErrors({ ...errors, expirydate: true }); + } else setErrors({ ...errors, expirydate: false }); + setCard({ ...card, expiryDate }); + } + function handlesave() { + if ( + !errors.cardType && + !errors.expirydate && + card.cardHolder && + card.cardNumber && + card.expiryDate && + card.cardType && + card.cvv + ) { + saveCard(card); + } + } + return ( +
+
+ {errors.cardType && ( +
Invalid Card
+ )} + +
+
+ +
+
+
+ {errors.expirydate && ( +
+ Invalid Expiry Date +
+ )} + +
+
+ +
+
+ +
+ ); +} diff --git a/src/components/Checkout/Checkout.tsx b/src/components/Checkout/Checkout.tsx new file mode 100644 index 00000000..c0ba2967 --- /dev/null +++ b/src/components/Checkout/Checkout.tsx @@ -0,0 +1,403 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import CardInput, { Card } from './CardInput'; +import { RootState } from '@/app/store'; +import { fetchCartItems } from '@/features/Cart/cartSlice'; +import { Checkout as CheckoutType } from '@/interfaces/checkout'; +import { + selectCheckout, + placeOrder, + makePayment, + updateStatus, +} from '@/features/Checkout/checkoutSlice'; +import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { + showSuccessToast, + showErrorToast, + showInfoToast, +} from '@/utils/ToastConfig'; + +function Direction({ rotate }: { rotate: number }) { + return ( + + + + ); +} + +function Checkout() { + const [chosen, setChosen] = useState('card'); + const [cards, setCards] = useState([] as Card[]); + const [adding, setAdding] = useState(false); + const [address, setAddress] = useState(''); + const [city, setCity] = useState(''); + const [coupon, setCoupon] = useState(''); + + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const user = useAppSelector((state) => state.signIn.user); + function handleAdding() { + setAdding(!adding); + } + const checkoutState = useAppSelector((state: RootState) => + selectCheckout(state) + ); + const order = checkoutState.checkout; + const { loading, error, paying } = checkoutState; + function handleSave(newCard: Card) { + setCards((prev) => [...prev, newCard]); + + const checkout: CheckoutType = { + deliveryInfo: { + address, + city, + zip: '12345', + }, + couponCode: coupon, + email: user?.email || '', + firstName: user?.firstName || '', + lastName: user?.lastName || '', + }; + dispatch(placeOrder(checkout)); + } + + function handlePayment() { + dispatch(makePayment(order.id)); + } + + useEffect(() => { + if (loading && paying) { + showInfoToast('Paying...'); + } else if (!loading && paying && !error) { + showSuccessToast('Succesfully Paid'); + dispatch(updateStatus(false)); + dispatch(fetchCartItems()); + navigate('/'); + } else if (paying && error) { + showErrorToast(error || 'failed'); + } + }, [error, loading, paying, navigate, dispatch]); + + return ( +
+
+

Delivery Address

+
+
+ +
+
+ +
+
+

Payment Methods

+ +
+ +
+ {chosen === 'momo' && ( +
+
+

+ My Momo Number +

+ +
+
+ +
+
+
+
+ )} + +
+ +
+ + {chosen === 'card' && ( +
+
+

+ My Cards +

+
+ {cards.map((card) => ( +
+ +
+ ))} + + {adding && ( + handleSave(newCard)} /> + )} +
+
+
+ )} +
+ +
+
+
+ {order.orderDetails.map((item) => ( + Cart item 1 + ))} +
+
+

Cart Collection

+

My saved collection

+

For summer sales

+
+
+ +
+

Promo Code

+
+ setCoupon(e.target.value)} + value={coupon} + /> + +
+ +
+
+ Total + ${order.totalAmount} +
+
+ +
+
+ Shipping + $0 +
+
+ +
+
+ Total Cost + ${order.totalAmount} +
+
+
+ + +
+
+ ); +} + +export default Checkout; diff --git a/src/components/Checkout/header.tsx b/src/components/Checkout/header.tsx new file mode 100644 index 00000000..c6afcc3e --- /dev/null +++ b/src/components/Checkout/header.tsx @@ -0,0 +1,60 @@ +import Item from '../home/headerItem'; + +interface HeaderItem { + image: string; + title: string; + description: string; + key: number; +} + +const headerItems: HeaderItem[] = [ + { + image: '/icons/icon1.svg', + title: 'Free Shipping', + description: 'Free shipping on all orders', + key: 1, + }, + { + image: '/icons/icon4.svg', + title: 'Online Support 24/7', + description: 'Support online 24 hours a day', + key: 2, + }, + { + image: '/icons/icon3.svg', + title: 'Money Return', + description: 'Back guarantee under 7 days', + key: 3, + }, + { + image: '/icons/icon2.svg', + title: 'Member Discount', + description: 'On every order over $20.00', + key: 4, + }, +]; + +export default function Header() { + return ( +
+
+

Complete Your Order

+ + You are just a few steps away to finsih + + your order... +
+
+ {headerItems.map((item) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index d5151fee..070831f3 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,5 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { LuShoppingCart } from 'react-icons/lu'; import { FiHeart } from 'react-icons/fi'; import { FaAngleDown } from 'react-icons/fa6'; @@ -7,6 +7,8 @@ import { RxHamburgerMenu } from 'react-icons/rx'; import HSButton from './form/HSButton'; import { logout } from '@/features/Auth/SignInSlice'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; +import { RootState } from '@/app/store'; +import { selectCartItems, fetchCartItems } from '@/features/Cart/cartSlice'; function Navbar() { const dispatch = useAppDispatch(); @@ -15,6 +17,13 @@ function Navbar() { const [toggleMenu, setToggleMenu] = useState(false); const [toggleProfileMenu, setToggleProfileMenu] = useState(false); const user = useAppSelector((state) => state.signIn.user); + const cartItems = useAppSelector((state: RootState) => + selectCartItems(state) + ); + + useEffect(() => { + dispatch(fetchCartItems()); + }, [dispatch]); return (
@@ -62,12 +71,12 @@ function Navbar() {
-
+
- 5 + {cartItems.length || 0}
-
+
@@ -31,7 +31,7 @@ function OrderDetailsModal({ close, order }: ModalProps) {

eric manzi

{billigDetails.address}

- {billigDetails.city}, {billigDetails.country} + {billigDetails.city}, {order.country}

@@ -39,7 +39,7 @@ function OrderDetailsModal({ close, order }: ModalProps) {

eric manzi

{billigDetails.address}

- {billigDetails.city}, {billigDetails.country} + {billigDetails.city}, {order.country}

diff --git a/src/components/Orders/Orders.tsx b/src/components/Orders/Orders.tsx index 7d0f4341..437bdf4c 100644 --- a/src/components/Orders/Orders.tsx +++ b/src/components/Orders/Orders.tsx @@ -23,7 +23,7 @@ export function Orders() { ); const [currentPage, setCurrentPage] = useState(1); - const [ordersPerPage] = useState(10); + const [ordersPerPage] = useState(5); const [sortColumn, setSortColumn] = useState(null); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); const [filterStatus, setFilterStatus] = useState('All'); @@ -60,7 +60,8 @@ export function Orders() { if (sortColumn) { const aValue = a[sortColumn as keyof typeof a]; const bValue = b[sortColumn as keyof typeof b]; - if (aValue === null || bValue === null) { + + if (!aValue || !bValue) { return 0; } if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1; @@ -75,7 +76,6 @@ export function Orders() { const results = sortedOrders.filter( (order) => order.id.toString().includes(query) || - order.deliveryInfo.toLowerCase().includes(query) || order.trackingNumber.toLowerCase().includes(query) || order.paymentInfo?.toLowerCase().includes(query) || order.updatedAt.toLowerCase().includes(query) diff --git a/src/components/dashBoard/DashboardSideNav.tsx b/src/components/dashBoard/DashboardSideNav.tsx index 5fba77a3..f828b8d5 100644 --- a/src/components/dashBoard/DashboardSideNav.tsx +++ b/src/components/dashBoard/DashboardSideNav.tsx @@ -17,7 +17,7 @@ const sideBarItems = [ icon: , }, { - path: '/orders', + path: '/dashboard/orders', name: 'Orders', icon: , }, diff --git a/src/features/Checkout/checkoutSlice.tsx b/src/features/Checkout/checkoutSlice.tsx new file mode 100644 index 00000000..88f5a058 --- /dev/null +++ b/src/features/Checkout/checkoutSlice.tsx @@ -0,0 +1,179 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { RootState } from '../../app/store'; +import { Checkout } from '@/interfaces/checkout'; +import Order from '@/interfaces/order'; + +export interface CheckoutState { + checkout: Order; + loading: boolean; + error: string | null; + paying: boolean; +} + +const initialOrder: Order = { + id: 31, + totalAmount: 160, + status: 'Pending', + couponCode: '', + deliveryInfo: { + address: '123 Main St', + city: 'Anytown', + zip: '12345', + }, + country: 'US', + paymentInfo: null, + trackingNumber: 'Tr280585', + createdAt: '2024-07-22T01:48:05.301Z', + updatedAt: '2024-07-22T11:01:20.291Z', + paid: true, + orderDetails: [ + { + id: 41, + quantity: 2, + price: 160, + }, + ], +}; + +const initialState: CheckoutState = { + checkout: initialOrder, + loading: false, + paying: false, + error: null, +}; + +const baseUrl = import.meta.env.VITE_BASE_URL; + +export const placeOrder = createAsyncThunk( + 'order/create', + async (order: Checkout) => { + const tokenFromStorage = localStorage.getItem('token') || ''; + const response = await axios.post( + `${baseUrl}/checkout`, + { ...order }, + { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + } + ); + return response.data.order; + } +); + +export const getOrders = createAsyncThunk('order/get', async () => { + const tokenFromStorage = localStorage.getItem('token') || ''; + const response = await axios.get(`${baseUrl}/checkout/getall-order`, { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + }); + return { + ...response.data[0], + deliveryInfo: JSON.parse(response.data[0].deliveryInfo), + }; +}); + +export const makePayment = createAsyncThunk( + 'order/pay', + async (orderId: number) => { + const tokenFromStorage = localStorage.getItem('token') || ''; + const response = await axios.post( + `${baseUrl}/buyer/payment`, + { + token: 'tok_visa', + orderId, + }, + { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + } + ); + return response.data; + } +); + +const checkoutSlice = createSlice({ + name: 'checkout', + initialState, + reducers: { + updateDeliveryInfo: ( + state, + action: PayloadAction> + ) => { + state.checkout.deliveryInfo = { + ...state.checkout.deliveryInfo, + ...action.payload, + }; + }, + updateCouponCode: (state, action: PayloadAction) => { + state.checkout.couponCode = action.payload; + }, + updateStatus: (state, action: PayloadAction) => { + state.paying = action.payload; + }, + updateEmail: (state, action: PayloadAction) => { + state.checkout.email = action.payload; + }, + updateFirstName: (state, action: PayloadAction) => { + state.checkout.firstName = action.payload; + }, + updateLastName: (state, action: PayloadAction) => { + state.checkout.lastName = action.payload; + }, + }, + extraReducers: (builder) => { + builder + .addCase(placeOrder.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(placeOrder.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.checkout = { ...state.checkout, ...action.payload }; + }) + .addCase(placeOrder.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to place order'; + }) + .addCase(getOrders.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getOrders.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.checkout = { ...state.checkout, ...action.payload[0] }; + }) + .addCase(getOrders.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Failed to fetch orders'; + }) + .addCase(makePayment.pending, (state) => { + state.loading = true; + state.error = null; + state.paying = true; + }) + .addCase(makePayment.fulfilled, (state) => { + state.loading = false; + }) + .addCase(makePayment.rejected, (state, action) => { + state.loading = false; + state.error = action.error.message || 'Payment failed'; + }); + }, +}); + +export const { + updateDeliveryInfo, + updateCouponCode, + updateEmail, + updateFirstName, + updateLastName, + updateStatus, +} = checkoutSlice.actions; + +export const selectCheckout = (state: RootState) => state.checkout; + +export default checkoutSlice.reducer; diff --git a/src/features/Orders/ordersSlice.ts b/src/features/Orders/ordersSlice.ts index 06161e70..2918ba7f 100644 --- a/src/features/Orders/ordersSlice.ts +++ b/src/features/Orders/ordersSlice.ts @@ -17,21 +17,25 @@ const initialState: OrdersState = { const baseUrl = import.meta.env.VITE_BASE_URL; -interface Payload { - orders: Order[]; -} - export const fetchOrders = createAsyncThunk('orders/fetchOrders', async () => { const tokenFromStorage = localStorage.getItem('token') || ''; - const response = await axios.get( - `${baseUrl}/checkout/getall-order`, - { - headers: { - Authorization: `Bearer ${tokenFromStorage}`, + const response = await axios.get(`${baseUrl}/checkout/getall-order`, { + headers: { + Authorization: `Bearer ${tokenFromStorage}`, + }, + }); + const res = response.data.orders; + const orders = res.map((order: any) => { + return { + ...order, + deliveryInfo: { + address: JSON.parse(order.deliveryInfo).address, + city: JSON.parse(order.deliveryInfo).city, + zip: JSON.parse(order.deliveryInfo).zip, }, - } - ); - return response.data.orders; + }; + }); + return orders; }); const ordersSlice = createSlice({ diff --git a/src/interfaces/checkout.ts b/src/interfaces/checkout.ts new file mode 100644 index 00000000..f3e4690d --- /dev/null +++ b/src/interfaces/checkout.ts @@ -0,0 +1,73 @@ +interface DeliveryInfo { + address: string; + city: string; + zip: string; +} + +export interface Checkout { + deliveryInfo: DeliveryInfo; + couponCode: string; + email: string; + firstName: string; + lastName: string; +} + +export interface Order { + msg: string; + order: { + user: { + id: number; + firstName: string; + lastName: string; + email: string; + password: string; + googleId: string | null; + facebookId: string | null; + picture: string; + provider: string | null; + isVerified: boolean; + status: string; + twoFactorCode: string | null; + createdAt: string; + updatedAt: string; + }; + totalAmount: number; + status: string; + deliveryInfo: { + address: string; + city: string; + zip: string; + }; + country: string; + trackingNumber: string; + orderDetails: { + product: { + id: number; + name: string; + image: string; + gallery: string[]; + shortDesc: string; + longDesc: string; + quantity: number; + regularPrice: number; + salesPrice: number; + tags: string[]; + type: string; + isAvailable: boolean; + isFeatured: boolean; + averageRating: number; + createdAt: string; + updatedAt: string; + }; + quantity: number; + price: number; + id: number; + }[]; + paymentInfo: null; + paid: boolean; + id: number; + createdAt: string; + updatedAt: string; + }; + trackingNumber: string; +} diff --git a/src/interfaces/order.ts b/src/interfaces/order.ts index 61edd65f..24b7a09c 100644 --- a/src/interfaces/order.ts +++ b/src/interfaces/order.ts @@ -1,8 +1,19 @@ +interface DeliveryInfo { + address: string; + city: string; + zip: string; +} + export interface Order { id: number; totalAmount: number; + country: string; status: string; - deliveryInfo: string; + couponCode?: string; + email?: string; + firstName?: string; + lastName?: string; + deliveryInfo: DeliveryInfo; paymentInfo: string | null; trackingNumber: string; createdAt: string; diff --git a/src/pages/Checkout.tsx b/src/pages/Checkout.tsx new file mode 100644 index 00000000..943612e5 --- /dev/null +++ b/src/pages/Checkout.tsx @@ -0,0 +1,11 @@ +import Checkout from '@/components/Checkout/Checkout'; +import Header from '@/components/Checkout/header'; + +export default function CheckoutPage() { + return ( +
+
+ +
+ ); +} diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index c6c04c7e..75dccb42 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -15,12 +15,13 @@ import EditProductPage from '@/pages/EditPage'; import Shop from '@/pages/Shop'; import ContactPage from '@/pages/contact'; import Wishlist from '@/pages/Wishlist'; -import { Orders } from '@/components/Orders/Orders'; +import Orders from '@/pages/Orders'; import AddProducts from '@/components/dashBoard/addProducts'; import ProductDetails from '@/pages/ProductDetails'; import ProtectedRoute from '@/components/ProtectedRoute'; import Cart from '@/components/Cart/Cart'; import Seller from '@/pages/Seller'; +import CheckoutPage from '@/pages/Checkout'; function AppRoutes() { return ( @@ -40,6 +41,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/tsconfig.json b/tsconfig.json index cce82ee2..d07df174 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": [".eslintrc.cjs", "src", "dist/Cart", "dist/Checkout"], + "include": [".eslintrc.cjs", "src", "dist/Cart", "src/components/Checkout"], "references": [{ "path": "./tsconfig.node.json" }] }