From 7e91a989b3e88c527b1f8370241f26502223681c Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sun, 26 Nov 2023 10:08:57 +0000 Subject: [PATCH] Added packages for students to be able to purchase --- package.json | 1 + src/components/PayPalPayment.tsx | 70 +++++++++++ src/dashboards/Student.tsx | 9 +- src/hooks/usePackages.tsx | 22 ++++ src/interfaces/paypal.ts | 23 ++++ src/interfaces/user.ts | 1 + src/pages/(register)/RegisterCorporate.tsx | 1 + src/pages/(status)/PaymentDue.tsx | 137 +++++++++++++++++++++ src/pages/_app.tsx | 2 +- src/pages/api/packages/index.ts | 44 +++++++ src/pages/api/paypal/approve.ts | 59 +++++++++ src/pages/api/paypal/index.ts | 47 +++++++ src/pages/index.tsx | 43 +++---- src/resources/paypal.ts | 98 +++++++++++++++ src/utils/paypal.ts | 25 ++++ yarn.lock | 5 + 16 files changed, 552 insertions(+), 35 deletions(-) create mode 100644 src/components/PayPalPayment.tsx create mode 100644 src/hooks/usePackages.tsx create mode 100644 src/interfaces/paypal.ts create mode 100644 src/pages/(status)/PaymentDue.tsx create mode 100644 src/pages/api/packages/index.ts create mode 100644 src/pages/api/paypal/approve.ts create mode 100644 src/pages/api/paypal/index.ts create mode 100644 src/resources/paypal.ts create mode 100644 src/utils/paypal.ts diff --git a/package.json b/package.json index 84191ef6..5b53fc0a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "clsx": "^1.2.1", "countries-list": "^3.0.1", "country-codes-list": "^1.6.11", + "currency-symbol-map": "^5.1.0", "daisyui": "^3.1.5", "eslint": "8.33.0", "eslint-config-next": "13.1.6", diff --git a/src/components/PayPalPayment.tsx b/src/components/PayPalPayment.tsx new file mode 100644 index 00000000..4240b900 --- /dev/null +++ b/src/components/PayPalPayment.tsx @@ -0,0 +1,70 @@ +import {DurationUnit} from "@/interfaces/paypal"; +import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OnCancelledActions, OrderResponseBody} from "@paypal/paypal-js"; +import {PayPalButtons, usePayPalScriptReducer} from "@paypal/react-paypal-js"; +import axios from "axios"; +import {useEffect, useState} from "react"; +import {toast} from "react-toastify"; + +interface Props { + currency: string; + price: number; + duration: number; + duration_unit: DurationUnit; + onSuccess: (duration: number, duration_unit: DurationUnit) => void; +} + +export default function PayPalPayment({price, currency, duration, duration_unit, onSuccess}: Props) { + const [{options}, dispatch] = usePayPalScriptReducer(); + + useEffect(() => { + dispatch({ + type: "resetOptions", + value: { + ...options, + currency, + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currency]); + + const createOrder = async (data: CreateOrderData, actions: CreateOrderActions): Promise => { + console.log(data, actions); + + return axios + .post("/api/paypal", {currencyCode: currency, price}) + .then((response) => response.data) + .then((data) => data.id); + }; + + const onApprove = async (data: OnApproveData, actions: OnApproveActions) => { + const request = await axios.post<{ok: boolean; reason?: string}>("/api/paypal/approve", {id: data.orderID, duration, duration_unit}); + + if (request.status !== 200) { + toast.error("Something went wrong, please try again later"); + return; + } + + toast.success("Your account has been credited more time!"); + return onSuccess(duration, duration_unit); + }; + + const onError = async (data: Record) => { + console.log(data); + toast.error("ERROR!"); + }; + + const onCancel = async (data: Record, actions: OnCancelledActions) => { + console.log(data, actions); + toast.error("CANCEL!"); + }; + + return ( + + ); +} diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index 474328cc..d93a0780 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -1,5 +1,6 @@ import Button from "@/components/Low/Button"; import ProgressBar from "@/components/Low/ProgressBar"; +import PayPalPayment from "@/components/PayPalPayment"; import ProfileSummary from "@/components/ProfileSummary"; import useAssignments from "@/hooks/useAssignments"; import useStats from "@/hooks/useStats"; @@ -9,13 +10,16 @@ import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {averageScore, groupBySession} from "@/utils/stats"; +import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {PayPalButtons} from "@paypal/react-paypal-js"; +import axios from "axios"; import clsx from "clsx"; import {capitalize} from "lodash"; import moment from "moment"; import Link from "next/link"; import {useRouter} from "next/router"; import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; +import {toast} from "react-toastify"; interface Props { user: User; @@ -84,11 +88,6 @@ export default function StudentDashboard({user}: Props) { -
- Payment - -
-
([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get("/api/packages") + .then((response) => setPackages(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, []); + + return {packages, isLoading, isError, reload: getData}; +} diff --git a/src/interfaces/paypal.ts b/src/interfaces/paypal.ts new file mode 100644 index 00000000..9b37a386 --- /dev/null +++ b/src/interfaces/paypal.ts @@ -0,0 +1,23 @@ +export interface TokenSuccess { + scope: string; + access_token: string; + token_type: string; + app_id: string; + expires_in: number; + nonce: string; +} + +export interface TokenError { + error: string; + error_description: string; +} + +export interface Package { + id: string; + currency: string; + duration: number; + duration_unit: DurationUnit; + price: number; +} + +export type DurationUnit = "weeks" | "days" | "months" | "years"; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 20852817..2e832a33 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -22,6 +22,7 @@ export interface User { export interface CorporateInformation { companyInformation: CompanyInformation; + monthlyDuration: number; payment?: { value: number; currency: string; diff --git a/src/pages/(register)/RegisterCorporate.tsx b/src/pages/(register)/RegisterCorporate.tsx index c9ff52f7..36e4f12b 100644 --- a/src/pages/(register)/RegisterCorporate.tsx +++ b/src/pages/(register)/RegisterCorporate.tsx @@ -67,6 +67,7 @@ export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, name: companyName, userAmount: companyUsers, }, + monthlyDuration: subscriptionDuration, referralAgent, }, }) diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx new file mode 100644 index 00000000..e5093943 --- /dev/null +++ b/src/pages/(status)/PaymentDue.tsx @@ -0,0 +1,137 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import PayPalPayment from "@/components/PayPalPayment"; +import ProfileSummary from "@/components/ProfileSummary"; +import useGroups from "@/hooks/useGroups"; +import usePackages from "@/hooks/usePackages"; +import useStats from "@/hooks/useStats"; +import useUsers from "@/hooks/useUsers"; +import {Package} from "@/interfaces/paypal"; +import {User} from "@/interfaces/user"; +import {averageScore, groupBySession} from "@/utils/stats"; +import clsx from "clsx"; +import {capitalize} from "lodash"; +import Head from "next/head"; +import {useState} from "react"; +import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; +import getSymbolFromCurrency from "currency-symbol-map"; +import {Divider} from "primereact/divider"; +import Input from "@/components/Low/Input"; +import Button from "@/components/Low/Button"; + +export default function PaymentDue({user, reload}: {user: User; reload: () => void}) { + const [selectedPackage, setPackage] = useState(); + const [code, setCode] = useState(); + + const {packages, isLoading} = usePackages(); + const {users} = useUsers(); + const {groups} = useGroups(); + + const isIndividual = () => { + if (user?.type === "developer") return true; + if (user?.type !== "student") return false; + const userGroups = groups.filter((g) => g.participants.includes(user?.id)); + + if (userGroups.length === 0) return true; + + const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); + return userGroupsAdminTypes.every((t) => t !== "admin"); + }; + + return user ? ( + +
+ You do not have time credits for your account type! + {isIndividual() && ( +
+ To add to your use of EnCoach, please purchase one of the time packages available below: +
+ {packages.map((p) => ( +
+
+ EnCoach's Logo + + EnCoach - {p.duration}{" "} + {capitalize(p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit)} + +
+
+ + {p.price} + {getSymbolFromCurrency(p.currency)} + + { + setTimeout(reload, 500); + }} + /> +
+
+ This includes: +
    +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
  • +
+
+
+ ))} +
+
+ )} + {!isIndividual() && user?.corporateInformation && user?.corporateInformation.payment && ( +
+ + To add to your use of EnCoach and that of your students and teachers, please pay your designated package below: + +
+
+ EnCoach's Logo + EnCoach - {user.corporateInformation?.monthlyDuration} Month(s) +
+
+ + {user.corporateInformation.payment.value} + {getSymbolFromCurrency(user.corporateInformation.payment.currency)} + + { + setTimeout(reload, 500); + }} + /> +
+
+ This includes: +
    +
  • + - Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to use + EnCoach +
  • +
  • - Train their abilities for the IELTS exam
  • +
  • - Gain insights into your students' weaknesses and strengths
  • +
  • - Allow them to correctly prepare for the exam
  • +
+
+
+
+ )} + {!isIndividual() && (!user?.corporateInformation || !user?.corporateInformation?.payment) && ( +
+ + An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you desire + and your expected monthly duration. + + Please try again again later or contact your agent or an admin, thank you for your patience. +
+ )} +
+
+ ) : ( +
+ ); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 897f64f2..e6ea6b0e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -34,7 +34,7 @@ export default function App({Component, pageProps}: AppProps) { return ( + options={{clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID || "", currency: "EUR", intent: "capture", commit: true, vault: true}}> ); diff --git a/src/pages/api/packages/index.ts b/src/pages/api/packages/index.ts new file mode 100644 index 00000000..a3d320ae --- /dev/null +++ b/src/pages/api/packages/index.ts @@ -0,0 +1,44 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {Group} from "@/interfaces/user"; +import {Package} from "@/interfaces/paypal"; +import {v4} from "uuid"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + if (req.method === "GET") await get(req, res); + if (req.method === "POST") await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const snapshot = await getDocs(collection(db, "packages")); + + res.status(200).json( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })), + ); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!["developer", "owner"].includes(req.session.user!.type)) + return res.status(403).json({ok: false, reason: "You do not have permission to create a new package"}); + + const body = req.body as Package; + + await setDoc(doc(db, "packages", v4()), body); + res.status(200).json({ok: true}); +} diff --git a/src/pages/api/paypal/approve.ts b/src/pages/api/paypal/approve.ts new file mode 100644 index 00000000..d9dbbc16 --- /dev/null +++ b/src/pages/api/paypal/approve.ts @@ -0,0 +1,59 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, setDoc, doc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import axios from "axios"; +import {DurationUnit, TokenError, TokenSuccess} from "@/interfaces/paypal"; +import {base64} from "@firebase/util"; +import {v4} from "uuid"; +import {OrderResponseBody} from "@paypal/paypal-js"; +import {getAccessToken} from "@/utils/paypal"; +import moment from "moment"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); + if (!req.session.user) return res.status(401).json({ok: false}); + + const accessToken = await getAccessToken(); + if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); + + const {id, duration, duration_unit} = req.body as {id: string; duration: number; duration_unit: DurationUnit}; + + const request = await axios.post( + `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders/${id}/capture`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (request.data.status === "COMPLETED") { + const subscriptionExpirationDate = req.session.user.subscriptionExpirationDate; + const today = moment(new Date()); + const dateToBeAddedTo = !subscriptionExpirationDate + ? today + : moment(subscriptionExpirationDate).isAfter(today) + ? moment(subscriptionExpirationDate) + : today; + + const updatedExpirationDate = dateToBeAddedTo.add(duration, duration_unit); + await setDoc( + doc(db, "users", req.session.user.id), + {subscriptionExpirationDate: updatedExpirationDate.toISOString(), status: "active"}, + {merge: true}, + ); + + res.status(200).json({ok: true}); + return; + } + + res.status(404).json({ok: false, reason: "Order ID not found or purchase was not approved!"}); +} diff --git a/src/pages/api/paypal/index.ts b/src/pages/api/paypal/index.ts new file mode 100644 index 00000000..139a33b7 --- /dev/null +++ b/src/pages/api/paypal/index.ts @@ -0,0 +1,47 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import axios from "axios"; +import {v4} from "uuid"; +import {OrderResponseBody} from "@paypal/paypal-js"; +import {getAccessToken} from "@/utils/paypal"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(404).json({ok: false, reason: "Method not supported!"}); + if (!req.session.user) return res.status(401).json({ok: false}); + + const accessToken = await getAccessToken(); + if (!accessToken) return res.status(401).json({ok: false, reason: "Authorization failed!"}); + + const {currencyCode, price} = req.body as {currencyCode: string; price: number}; + + const request = await axios.post( + `${process.env.PAYPAL_ACCESS_TOKEN_URL}/v2/checkout/orders`, + { + purchase_units: [ + { + amount: { + currency_code: currencyCode, + value: price.toString(), + }, + reference_id: v4(), + }, + ], + intent: "CAPTURE", + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + res.status(request.status).json(request.data); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 01050515..e249d187 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -27,6 +27,8 @@ import AdminDashboard from "@/dashboards/Admin"; import CorporateDashboard from "@/dashboards/Corporate"; import TeacherDashboard from "@/dashboards/Teacher"; import AgentDashboard from "@/dashboards/Agent"; +import PaymentDue from "./(status)/PaymentDue"; +import {useRouter} from "next/router"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -51,6 +53,8 @@ export default function Home() { const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDemographicInput, setShowDemographicInput] = useState(false); const {user, mutateUser} = useUser({redirectTo: "/login"}); + const {stats} = useStats(user?.id); + const router = useRouter(); useEffect(() => { if (user) { @@ -68,7 +72,7 @@ export default function Home() { return true; }; - if (user && (user.status === "disabled" || checkIfUserExpired())) { + if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) { return ( <> @@ -80,34 +84,15 @@ export default function Home() { - -
- {user.status === "disabled" ? ( - <> - Your account has been disabled! - Please contact an administrator if you believe this to be a mistake. - - ) : ( - <> - Your subscription has expired! -
- - Please purchase a new time pack{" "} - - here - - . - - - If you are not the one in charge of your subscription, please contact the one responsible to extend it. - -
- - )} -
-
+ {user.status === "disabled" && ( + +
+ Your account has been disabled! + Please contact an administrator if you believe this to be a mistake. +
+
+ )} + {(user.status === "paymentDue" || checkIfUserExpired()) && } ); } diff --git a/src/resources/paypal.ts b/src/resources/paypal.ts new file mode 100644 index 00000000..7cd4a838 --- /dev/null +++ b/src/resources/paypal.ts @@ -0,0 +1,98 @@ +export const CURRENCIES: {label: string; currency: string}[] = [ + { + label: "Australian dollar", + currency: "AUD", + }, + { + label: "Brazilian real 2", + currency: "BRL", + }, + { + label: "Canadian dollar", + currency: "CAD", + }, + { + label: "Chinese Renmenbi 3", + currency: "CNY", + }, + { + label: "Czech koruna", + currency: "CZK", + }, + { + label: "Danish krone", + currency: "DKK", + }, + { + label: "Euro", + currency: "EUR", + }, + { + label: "Hong Kong dollar", + currency: "HKD", + }, + { + label: "Hungarian forint 1", + currency: "HUF", + }, + { + label: "Israeli new shekel", + currency: "ILS", + }, + { + label: "Japanese yen 1", + currency: "JPY", + }, + { + label: "Malaysian ringgit 3", + currency: "MYR", + }, + { + label: "Mexican peso", + currency: "MXN", + }, + { + label: "New Taiwan dollar 1", + currency: "TWD", + }, + { + label: "New Zealand dollar", + currency: "NZD", + }, + { + label: "Norwegian krone", + currency: "NOK", + }, + { + label: "Philippine peso", + currency: "PHP", + }, + { + label: "Polish złoty", + currency: "PLN", + }, + { + label: "Pound sterling", + currency: "GBP", + }, + { + label: "Singapore dollar", + currency: "SGD", + }, + { + label: "Swedish krona", + currency: "SEK", + }, + { + label: "Swiss franc", + currency: "CHF", + }, + { + label: "Thai baht", + currency: "THB", + }, + { + label: "United States dollar", + currency: "USD", + }, +]; diff --git a/src/utils/paypal.ts b/src/utils/paypal.ts new file mode 100644 index 00000000..8d47f302 --- /dev/null +++ b/src/utils/paypal.ts @@ -0,0 +1,25 @@ +import {TokenError, TokenSuccess} from "@/interfaces/paypal"; +import {base64} from "@firebase/util"; +import axios from "axios"; + +export const getAccessToken = async () => { + const params = new URLSearchParams(); + params.append("grant_type", "client_credentials"); + + const auth = base64.encodeString(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`); + + const request = await axios + .post(`${process.env.PAYPAL_ACCESS_TOKEN_URL}/v1/oauth2/token`, params, { + headers: {Authorization: `Basic ${auth}`}, + }) + .catch((e) => { + console.log(e); + return undefined; + }); + + if (!request) { + return undefined; + } + + return request.data.access_token; +}; diff --git a/yarn.lock b/yarn.lock index 9bc9cee8..d7ba109c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1949,6 +1949,11 @@ csstype@^3.0.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz" integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== +currency-symbol-map@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/currency-symbol-map/-/currency-symbol-map-5.1.0.tgz#59531fbe977ba95e8d358e90e3c9e9053efb75ad" + integrity sha512-LO/lzYRw134LMDVnLyAf1dHE5tyO6axEFkR3TXjQIOmMkAM9YL6QsiUwuXzZAmFnuDJcs4hayOgyIYtViXFrLw== + daisyui@^3.1.5: version "3.5.1" resolved "https://registry.npmjs.org/daisyui/-/daisyui-3.5.1.tgz"