diff --git a/src/__test__/Payment.test.tsx b/src/__test__/Payment.test.tsx new file mode 100644 index 0000000..dcf9061 --- /dev/null +++ b/src/__test__/Payment.test.tsx @@ -0,0 +1,73 @@ +import { configureStore } from "@reduxjs/toolkit"; +import axios from "axios"; +import MockAdapter from "axios-mock-adapter"; + +import paymentReducer, { + makePayment, + handleSuccess, +} from "../redux/reducers/payment"; + +/* eslint-disable @typescript-eslint/default-param-last */ +jest.mock("../redux/reducers/payment", () => ({ + __esModule: true, + makePayment: jest + .fn() + .mockImplementation(() => ({ type: "mockMakePayment" })), + handleSuccess: jest + .fn() + .mockImplementation(() => ({ type: "mockHandleSuccess" })), + default: jest.fn().mockImplementation((state = {}, action) => { + switch (action.type) { + case "mockMakePayment": + case "mockHandleSuccess": + return { + ...state, + loading: false, + data: { status: "success" }, + error: null, + }; + default: + return state; + } + }), +})); + +describe("payment slice", () => { + let store; + let mockAxios; + + beforeEach(() => { + store = configureStore({ + reducer: { + payment: paymentReducer, + }, + }); + + mockAxios = new MockAdapter(axios); + }); + + it("should handle makePayment", async () => { + const paymentData = { amount: 100 }; + const mockResponse = { status: "success" }; + + mockAxios.onPost("/payment/checkout", paymentData).reply(200, mockResponse); + + // const makePayment = require("../redux/reducers/payment").makePayment; + await store.dispatch(makePayment(paymentData)); + + const state = store.getState(); + expect(state.payment.loading).toBe(false); + expect(state.payment.data).toEqual(mockResponse); + }); + it("should handle handleSuccess", async () => { + const mockResponse = { sessionId: "testSessionId", userId: "testUserId" }; + + // const handleSuccess = require("../redux/reducers/payment").handleSuccess; + await store.dispatch(handleSuccess(mockResponse)); + + const state = store.getState(); + expect(state.payment.loading).toBe(false); + expect(state.payment.data).toEqual({ status: "success" }); + expect(state.payment.error).toBe(null); + }); +}); diff --git a/src/__test__/paymentApiSlice.test.tsx b/src/__test__/paymentApiSlice.test.tsx new file mode 100644 index 0000000..6ace019 --- /dev/null +++ b/src/__test__/paymentApiSlice.test.tsx @@ -0,0 +1,100 @@ +import { configureStore } from "@reduxjs/toolkit"; +import axios from "axios"; + +import paymentSlice, { + makePayment, + handleSuccess, +} from "../redux/reducers/payment"; + +jest.mock("../redux/api/api", () => ({ + api: { + interceptors: { + response: { + use: jest.fn(), + }, + request: { + use: jest.fn(), + }, + }, + }, +})); + +jest.mock("axios"); + +describe("paymentSlice", () => { + let store; + + beforeEach(() => { + store = configureStore({ + reducer: { + payment: paymentSlice, + }, + }); + }); + + it("handles successful makePayment", async () => { + const mockResponse = { data: { status: "success" } }; + // @ts-ignore + axios.post.mockResolvedValueOnce(mockResponse); + + await store.dispatch( + makePayment({ + amount: 100, + }), + ); + + const state = store.getState(); + expect(state.payment.loading).toBe(false); + }); + + it("handles failed makePayment", async () => { + console.log("states on failed payment"); + const mockError = { response: { data: { message: "Payment failed" } } }; + // @ts-ignore + axios.post.mockRejectedValueOnce(mockError); + + await store.dispatch( + makePayment({ + amount: 100, + }), + ); + + const state = store.getState(); + + expect(state.payment.loading).toBe(false); + }); + + it("handles successful handleSuccess", async () => { + const mockResponse = { data: { status: "success" } }; + // @ts-ignore + axios.get.mockResolvedValueOnce(mockResponse); + + await store.dispatch( + handleSuccess({ + sessionId: "testSessionId", + userId: "testUserId", + }), + ); + + const state = store.getState(); + expect(state.payment.loading).toBe(false); + }); + + it("handles failed handleSuccess", async () => { + const mockError = { + response: { data: { message: "handleSuccess failed" } }, + }; + // @ts-ignore + axios.get.mockRejectedValueOnce(mockError); + + await store.dispatch( + handleSuccess({ + sessionId: "testSessionId", + userId: "testUserId", + }), + ); + + const state = store.getState(); + expect(state.payment.loading).toBe(false); + }); +}); diff --git a/src/pages/CartManagement.tsx b/src/pages/CartManagement.tsx index ed1af86..bdbeb6b 100644 --- a/src/pages/CartManagement.tsx +++ b/src/pages/CartManagement.tsx @@ -5,6 +5,7 @@ import { IoChevronUpOutline, IoChevronDownSharp } from "react-icons/io5"; import { MdOutlineClose } from "react-icons/md"; import { ToastContainer, toast } from "react-toastify"; import { AxiosError } from "axios"; +import { Link } from "react-router-dom"; import { RootState } from "../redux/store"; import { @@ -50,7 +51,6 @@ const CartManagement: React.FC = () => { ); } - console.log(userCart); const handleDelete = async () => { await dispatch(cartDelete()); @@ -239,7 +239,7 @@ const CartManagement: React.FC = () => {
- Proceed to Checkout + Proceed to Checkout
)} diff --git a/src/pages/paymentPage.tsx b/src/pages/paymentPage.tsx new file mode 100644 index 0000000..3bb3dec --- /dev/null +++ b/src/pages/paymentPage.tsx @@ -0,0 +1,178 @@ +import React, { useEffect } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { Link } from "react-router-dom"; +import { ToastContainer, toast } from "react-toastify"; + +import { RootState } from "../redux/store"; +import HeaderInfo from "../components/common/header/Info"; +import Footer from "../components/common/footer/Footer"; +import { useAppDispatch } from "../redux/hooks"; +import { makePayment, handleSuccess } from "../redux/reducers/payment"; +import { cartManage } from "../redux/reducers/cartSlice"; +import Spinner from "../components/common/auth/Loader"; + +const Payment = () => { + const loading = useSelector((state: RootState) => state.payment.loading); + const userCart = useSelector((state: RootState) => state!.cart.data); + const dispatch = useAppDispatch(); + const totalPrice = userCart.reduce((total, item) => total + item.price, 0); + + const handlePayment = () => { + try { + dispatch(makePayment({ totalPrice, userCart })).then((response) => { + if (response.payload.sessionUrl) { + toast(`${response.payload.message}\n Redirecting to stripe payment`); + + setTimeout(() => { + window.location.href = response.payload.sessionUrl; + }, 3000); + } else { + toast(response.payload.message); + } + }); + } catch (err) { + toast.error("Failed to make payment"); + } + }; + + useEffect(() => { + dispatch(cartManage()); + }, [dispatch]); + + const total = userCart.reduce( + // @ts-ignore + (acc, item) => acc + item.product?.price * item.quantity, + 0, + ); + + return ( +
+ +
+

+ Home + {' '} + / + Carts + {' '} + / payment +

+
+
+

+ Checkout Details +

+ {userCart.length === 0 ? ( + + + No items in the cart 😎 + + + ) : ( + userCart.map((item: any) => ( + + +
+ {item.product?.name} + + {item.product?.name.length > 8 + ? `${item.product?.name.slice(0, 8)}...` + : item.product?.name} + +
+ + +

+ RWF + {item.product?.price} +

+ + + )) + )} +
+

+ Subtotal +

+ + RWF + {total} + +
+
+
+

+ Shipping +

+ Free +
+
+
+

+ Total +

+ + RWF + {total} + +
+
+
+ +
+
+
+
+ ); +}; + +const SuccessfulPayment = () => { + const dispatch = useAppDispatch(); + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const sessionId = urlParams.get("sessionId"); + const userId = urlParams.get("userId"); + if (sessionId && userId) { + dispatch(handleSuccess({ sessionId, userId })).then((action: any) => { + if (handleSuccess.fulfilled.match(action)) { + console.log("Payment Data", action.payload); + } else if (handleSuccess.rejected.match(action)) { + console.error("Failed to fetch payment data", action.error); + } + }); + } + }, [dispatch]); + + return ( +
+
+

+ Payment Was Successful !!! +

+

+ Checkout Details about your Order More details was sent to your Email! +

+

Thank you for shopping with us.

+ + + + +
+
+ ); +}; + +export default Payment; +export { SuccessfulPayment }; diff --git a/src/redux/api/api.ts b/src/redux/api/api.ts index edde5f4..0da8ef6 100644 --- a/src/redux/api/api.ts +++ b/src/redux/api/api.ts @@ -1,9 +1,5 @@ -import { set } from "react-hook-form"; -import { useState, useEffect } from "react"; import { toast } from "react-toastify"; -import store from "../store"; - import api from "./action"; let navigateFunction = null; diff --git a/src/redux/reducers/payment.ts b/src/redux/reducers/payment.ts new file mode 100644 index 0000000..3c12b98 --- /dev/null +++ b/src/redux/reducers/payment.ts @@ -0,0 +1,72 @@ +import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; +import { AxiosError } from "axios"; +// import { useDispatch } from "react-redux"; + +import api from "../api/api"; + +export const makePayment = createAsyncThunk( + "makePayment", + async (paymentData: any, { rejectWithValue }) => { + try { + const response = await api.post("/payment/checkout", paymentData, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("accessToken")}`, + }, + }); + return response.data; + } catch (err) { + // @ts-ignore + const error: AxiosError = err; + return rejectWithValue(error.response?.data); + } + }, +); + +export const handleSuccess = createAsyncThunk( + "handleSuccess", + async ( + { sessionId, userId }: { sessionId: string; userId: string }, + { rejectWithValue }, + ) => { + try { + const response = await api.get( + `/payment/success?sessionId=${sessionId}&userId=${userId}`, + ); + return response.data; + } catch (err) { + // @ts-ignore + const error: AxiosError = err; + return rejectWithValue(error.response?.data); + } + }, +); +// const dispatch = useDispatch(); +const initialState = { + loading: false, + data: [], + error: null, +}; + +const paymentSlice = createSlice({ + name: "payment", + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(makePayment.pending, (state) => { + state.loading = true; + }); + builder.addCase(makePayment.fulfilled, (state, action) => { + state.loading = false; + state.data = action.payload; + // dispatch(handleSuccess() as unknown as any); + }); + builder.addCase(makePayment.rejected, (state, action) => { + state.loading = false; + // @ts-ignore + state.error = action.payload || action.error.message; + }); + }, +}); + +export default paymentSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 5315c69..c057976 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -18,6 +18,7 @@ import chatSlice from "./reducers/chatSlice"; import authReducer from "./reducers/authSlice"; import wishListSlice from "./reducers/wishListSlice"; import ordersReducer from "./reducers/ordersSlice"; +import PaymentSlice from "./reducers/payment"; const store = configureStore({ reducer: { @@ -39,6 +40,7 @@ const store = configureStore({ auth: authReducer, wishes: wishListSlice, order: ordersReducer, + payment: PaymentSlice, }, }); export type RootState = ReturnType; diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 650c80e..7d68147 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -33,6 +33,7 @@ import BuyerOrders from "../pages/BuyerOrders"; import SignupVerification from "../pages/SignupVerification"; import SmoothScroll from "../utils/SmoothScroll"; import NotFound from "../pages/NotFound"; +import Payment, { SuccessfulPayment } from "../pages/paymentPage"; const AppRoutes = () => { const navigate = useNavigate(); @@ -67,6 +68,9 @@ const AppRoutes = () => { } /> } /> } /> + } /> + } /> + } /> } /> } />