Updated part of the payment

This commit is contained in:
Tiago Ribeiro
2024-12-30 18:39:02 +00:00
parent 17154be8bf
commit f64b50df9e
11 changed files with 457 additions and 357 deletions

View File

@@ -1,8 +1,8 @@
import useEntities from "@/hooks/useEntities"; import useEntities from "@/hooks/useEntities";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import {User} from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
@@ -23,19 +23,19 @@ export default function Layout({
user, user,
children, children,
className, className,
bgColor="bg-white", bgColor = "bg-white",
hideSidebar, hideSidebar,
navDisabled = false, navDisabled = false,
focusMode = false, focusMode = false,
onFocusLayerMouseEnter onFocusLayerMouseEnter
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const {entities} = useEntities() const { entities } = useEntities()
return ( return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}> <main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer /> <ToastContainer />
{!hideSidebar && ( {!hideSidebar && user && (
<Navbar <Navbar
path={router.pathname} path={router.pathname}
user={user} user={user}
@@ -45,7 +45,7 @@ export default function Layout({
/> />
)} )}
<div className={clsx("h-full w-full flex gap-2")}> <div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && ( {!hideSidebar && user && (
<Sidebar <Sidebar
path={router.pathname} path={router.pathname}
navDisabled={navDisabled} navDisabled={navDisabled}

View File

@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean; disabled?: boolean;
max?: number; max?: number;
min?: number; min?: number;
thin?: boolean
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -29,6 +30,7 @@ export default function Input({
className, className,
roundness = "full", roundness = "full",
disabled = false, disabled = false,
thin = false,
min, min,
onChange, onChange,
}: Props) { }: Props) {
@@ -95,9 +97,10 @@ export default function Input({
min={type === "number" ? (min ?? 0) : undefined} min={type === "number" ? (min ?? 0) : undefined}
placeholder={placeholder} placeholder={placeholder}
className={clsx( className={clsx(
"px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none",
"placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed", "placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed",
roundness === "full" ? "rounded-full" : "rounded-xl", roundness === "full" ? "rounded-full" : "rounded-xl",
thin ? 'py-4' : 'py-6'
)} )}
required={required} required={required}
defaultValue={defaultValue} defaultValue={defaultValue}

View File

@@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa
{ {
module: "reading", module: "reading",
icon: () => <BsBook className="h-4 w-4 text-white" />, icon: () => <BsBook className="h-4 w-4 text-white" />,
achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9, achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9,
}, },
{ {
module: "listening", module: "listening",
icon: () => <BsHeadphones className="h-4 w-4 text-white" />, icon: () => <BsHeadphones className="h-4 w-4 text-white" />,
achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9, achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9,
}, },
{ {
module: "writing", module: "writing",
icon: () => <BsPen className="h-4 w-4 text-white" />, icon: () => <BsPen className="h-4 w-4 text-white" />,
achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9, achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9,
}, },
{ {
module: "speaking", module: "speaking",
icon: () => <BsMegaphone className="h-4 w-4 text-white" />, icon: () => <BsMegaphone className="h-4 w-4 text-white" />,
achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9, achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9,
}, },
{ {
module: "level", module: "level",
icon: () => <BsClipboard className="h-4 w-4 text-white" />, icon: () => <BsClipboard className="h-4 w-4 text-white" />,
achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9, achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9,
}, },
]; ];

View File

@@ -1,15 +1,17 @@
import {PaymentIntention} from "@/interfaces/paymob"; import { Entity } from "@/interfaces/entity";
import {DurationUnit} from "@/interfaces/paypal"; import { PaymentIntention } from "@/interfaces/paymob";
import {User} from "@/interfaces/user"; import { DurationUnit } from "@/interfaces/paypal";
import { User } from "@/interfaces/user";
import axios from "axios"; import axios from "axios";
import {useRouter} from "next/router"; import { useRouter } from "next/router";
import {useState} from "react"; import { useState } from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
import Input from "./Low/Input"; import Input from "./Low/Input";
import Modal from "./Modal"; import Modal from "./Modal";
interface Props { interface Props {
user: User; user: User;
entity?: Entity
currency: string; currency: string;
price: number; price: number;
setIsPaymentLoading: (v: boolean) => void; setIsPaymentLoading: (v: boolean) => void;
@@ -18,7 +20,7 @@ interface Props {
onSuccess: (duration: number, duration_unit: DurationUnit) => void; onSuccess: (duration: number, duration_unit: DurationUnit) => void;
} }
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -56,10 +58,11 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
userID: user.id, userID: user.id,
duration, duration,
duration_unit, duration_unit,
entity: entity?.id
}, },
}; };
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); const response = await axios.post<{ iframeURL: string }>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL); router.push(response.data.iframeURL);
} catch (error) { } catch (error) {

View File

@@ -28,7 +28,7 @@ interface Customer {
extras: IntentionExtras; extras: IntentionExtras;
} }
type IntentionExtras = {[key: string]: string | number}; type IntentionExtras = { [key: string]: string | number | undefined };
export interface IntentionResult { export interface IntentionResult {
payment_keys: PaymentKeysItem[]; payment_keys: PaymentKeysItem[];

View File

@@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers"; import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import { capitalize } from "lodash"; import { capitalize, sortBy } from "lodash";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites"; import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs"; import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard"; import InviteCard from "@/components/Medium/InviteCard";
@@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment"; import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment"; import moment from "moment";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { Discount, Package } from "@/interfaces/paypal";
import { isAdmin } from "@/utils/users";
import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import Select from "@/components/Low/Select";
interface Props { interface Props {
user: User; user: User
discounts: Discount[]
packages: Package[]
entities: EntityWithRoles[] entities: EntityWithRoles[]
hasExpired?: boolean; hasExpired?: boolean;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ user, hasExpired = false, reload }: Props) { export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0); const [entity, setEntity] = useState<EntityWithRoles>()
const router = useRouter(); const router = useRouter();
const { packages } = usePackages();
const { discounts } = useDiscounts();
const { users } = useUsers(); const { users } = useUsers();
const { groups } = useGroups({});
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
useEffect(() => { const isIndividual = useMemo(() => {
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); if (isAdmin(user)) return false;
if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return;
setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]);
const isIndividual = () => {
if (user?.type === "developer") return true;
if (user?.type !== "student") return false; if (user?.type !== "student") return false;
const userGroups = groups.filter((g) => g.participants.includes(user?.id));
if (userGroups.length === 0) return true; return user.entities.length === 0
}, [user])
const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); const appliedDiscount = useMemo(() => {
return userGroupsAdminTypes.every((t) => t !== "corporate"); const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift();
};
if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment())))
return 0;
return biggestDiscount.percentage
}, [discounts])
const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity')
useEffect(() => {
if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0])
}, [entitiesThatCanBePaid])
return ( return (
<> <>
@@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div> </div>
</div> </div>
)} )}
{user ? ( <Layout user={user} navDisabled={hasExpired}>
<Layout user={user} navDisabled={hasExpired}> {invites.length > 0 && (
{invites.length > 0 && ( <section className="flex flex-col gap-1 md:gap-3">
<section className="flex flex-col gap-1 md:gap-3"> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <div
<div onClick={reloadInvites}
onClick={reloadInvites} className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"> <span className="text-mti-black text-lg font-bold">Invites</span>
<span className="text-mti-black text-lg font-bold">Invites</span> <BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
<BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
</div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> </div>
{invites.map((invite) => ( <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
<InviteCard {invites.map((invite) => (
key={invite.id} <InviteCard
invite={invite} key={invite.id}
users={users} invite={invite}
reload={() => { users={users}
reloadInvites(); reload={() => {
router.reload(); reloadInvites();
}} router.reload();
/> }}
))} />
</span> ))}
</section> </span>
)} </section>
)}
<div className="flex w-full flex-col items-center justify-center gap-4 text-center"> <div className="flex w-full flex-col items-center justify-center gap-4 text-center">
{hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>} {hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
{isIndividual() && ( {isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below: To add to your use of EnCoach, please purchase one of the time packages available below:
</span> </span>
<div className="flex w-full flex-wrap justify-center gap-8"> <div className="flex w-full flex-wrap justify-center gap-8">
{packages.map((p) => ( {packages.map((p) => (
<div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}> <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold">
EnCoach - {p.duration}{" "}
{capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)}
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
{appliedDiscount === 0 && (
<span className="text-2xl">
{p.price} {p.currency}
</span>
)}
{appliedDiscount > 0 && (
<div className="flex items-center gap-2">
<span className="text-2xl line-through">
{p.price} {p.currency}
</span>
<span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
</span>
</div>
)}
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
onSuccess={() => {
setTimeout(reload, 500);
}}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
/>
</div>
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>- Train your abilities for the IELTS exam</li>
<li>- Gain insights into your weaknesses and strengths</li>
<li>- Allow yourself to correctly prepare for the exam</li>
</ul>
</div>
</div>
))}
</div>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
user?.corporateInformation.payment && (
<div className="flex flex-col items-center">
<span className="max-w-lg">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
below:
</span>
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start"> <div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" /> <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {12} Months EnCoach - {p.duration}{" "}
{capitalize(
p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
)}
</span> </span>
</div> </div>
<div className="flex w-full flex-col items-start gap-2"> <div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl"> {appliedDiscount === 0 && (
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} <span className="text-2xl">
</span> {p.price} {p.currency}
</span>
)}
{appliedDiscount > 0 && (
<div className="flex items-center gap-2">
<span className="text-2xl line-through">
{p.price} {p.currency}
</span>
<span className="text-2xl text-mti-red-light">
{(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
</span>
</div>
)}
<PaymobPayment <PaymobPayment
user={user} user={user}
setIsPaymentLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value}
duration={12}
duration_unit="months"
onSuccess={() => { onSuccess={() => {
setIsLoading(false);
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
currency={p.currency}
duration={p.duration}
duration_unit={p.duration_unit}
price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
/> />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<span>This includes:</span> <span>This includes:</span>
<ul className="flex flex-col items-start text-sm"> <ul className="flex flex-col items-start text-sm">
<li> <li>- Train your abilities for the IELTS exam</li>
- Allow a total of 0 students and teachers to use EnCoach <li>- Gain insights into your weaknesses and strengths</li>
</li> <li>- Allow yourself to correctly prepare for the exam</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
<li>- Allow them to correctly prepare for the exam</li>
</ul> </ul>
</div> </div>
</div> </div>
))}
</div>
</div>
)}
{!isIndividual && entitiesThatCanBePaid.length > 0 &&
entity?.payment && (
<div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: entity?.id, label: entity?.label }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div> </div>
)}
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
<div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation. To add to your use of EnCoach and that of your students and teachers, please pay your designated package
below:
</span>
<div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
<div className="mb-2 flex flex-col items-start">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<span className="text-xl font-semibold">
EnCoach - {12} Months
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{entity.payment.price} {entity.payment.currency}
</span>
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
entity={entity}
currency={entity.payment.currency}
price={entity.payment.price}
duration={12}
duration_unit="months"
onSuccess={() => {
setIsLoading(false);
setTimeout(reload, 500);
}}
/>
</div>
<div className="flex flex-col items-start gap-1">
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>
- Allow a total of {entity.licenses} students and teachers to use EnCoach
</li>
<li>- Train their abilities for the IELTS exam</li>
<li>- Gain insights into your students&apos; weaknesses and strengths</li>
<li>- Allow them to correctly prepare for the exam</li>
</ul>
</div>
</div>
</div>
)}
{!isIndividual && entitiesThatCanBePaid.length === 0 && (
<div className="flex flex-col items-center">
<span className="max-w-lg">
You are not the person in charge of your time credits, please contact your administrator about this situation.
</span>
<span className="max-w-lg">
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your
patience.
</span>
</div>
)}
{!isIndividual &&
entitiesThatCanBePaid.length > 0 &&
!entity?.payment && (
<div className="flex flex-col items-center gap-8">
<div className={clsx("flex flex-col items-center gap-4 w-full")}>
<label className="font-normal text-base text-mti-gray-dim">Entity</label>
<Select
defaultValue={{ value: entity?.id || "", label: entity?.label || "" }}
options={entitiesThatCanBePaid.map((e) => ({ value: e.id, label: e.label, entity: e }))}
onChange={(e) => e?.value ? setEntity(e?.entity) : null}
className="!w-full max-w-[400px] self-center"
/>
</div>
<span className="max-w-lg">
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.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your Please try again later or contact your agent or an admin, thank you for your patience.
patience.
</span> </span>
</div> </div>
)} )}
{!isIndividual() && </div>
(user?.type === "corporate" || user?.type === "mastercorporate") && </Layout>
!user.corporateInformation.payment && (
<div className="flex flex-col items-center">
<span className="max-w-lg">
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.
</span>
<span className="max-w-lg">
Please try again later or contact your agent or an admin, thank you for your patience.
</span>
</div>
)}
</div>
</Layout>
) : (
<div />
)}
</> </>
); );
} }

View File

@@ -68,6 +68,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ ok: entity.acknowledged }); return res.status(200).json({ ok: entity.acknowledged });
} }
if (req.body.payment) {
const entity = await db.collection<Entity>("entities").updateOne({ id }, { $set: { payment: req.body.payment } });
return res.status(200).json({ ok: entity.acknowledged });
}
if (req.body.expiryDate !== undefined) { if (req.body.expiryDate !== undefined) {
const entity = await getEntity(id) const entity = await getEntity(id)
const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } });

View File

@@ -1,15 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import {withIronSessionApiRoute} from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {Group, User} from "@/interfaces/user"; import { Group, User } from "@/interfaces/user";
import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; import { DurationUnit, Package, Payment } from "@/interfaces/paypal";
import {v4} from "uuid"; import { v4 } from "uuid";
import ShortUniqueId from "short-unique-id"; import ShortUniqueId from "short-unique-id";
import axios from "axios"; import axios from "axios";
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
import moment from "moment"; import moment from "moment";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { getEntity } from "@/utils/entities.be";
import { Entity } from "@/interfaces/entity";
import { getEntityUsers } from "@/utils/users.be";
import { mapBy } from "@/utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
const authToken = await authenticatePaymob(); const authToken = await authenticatePaymob();
console.log("WEBHOOK: ", transactionResult); console.log("WEBHOOK: ", transactionResult);
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false });
if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); if (!transactionResult.transaction.success) return res.status(400).json({ ok: false });
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as {
userID: string; userID: string;
duration: number; duration: number;
duration_unit: DurationUnit; duration_unit: DurationUnit;
entity: string
}; };
const user = await db.collection("users").findOne<User>({ id: userID as string }); const user = await db.collection("users").findOne<User>({ id: userID as string });
if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false });
const subscriptionExpirationDate = user.subscriptionExpirationDate; const subscriptionExpirationDate = user.subscriptionExpirationDate;
if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); if (!subscriptionExpirationDate) return res.status(200).json({ ok: false });
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
@@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
await db.collection("users").updateOne( await db.collection("users").updateOne(
{ id: userID as string }, { id: userID as string },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } }
); );
await db.collection("paypalpayments").insertOne({ await db.collection("paypalpayments").insertOne({
id: v4(), id: v4(),
@@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
value: transactionResult.transaction.amount_cents / 1000, value: transactionResult.transaction.amount_cents / 1000,
}); });
if (user.type === "corporate") { if (entityID) {
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray(); const entity = await getEntity(entityID)
await db.collection<Entity>("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } });
const participants = (await Promise.all( const users = await getEntityUsers(entityID, 0, {
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), subscriptionExpirationDate: entity?.expiryDate,
)) as User[]; $and: [
const sameExpiryDateParticipants = participants.filter( { type: { $ne: "admin" } },
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", { type: { $ne: "developer" } },
); ]
})
for (const participant of sameExpiryDateParticipants) { await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
await db.collection("users").updateOne(
{ id: participant.id },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
);
}
} }
res.status(200).json({ res.status(200).json({
@@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
} }
const authenticatePaymob = async () => { const authenticatePaymob = async () => {
const response = await axios.post<{token: string}>( const response = await axios.post<{ token: string }>(
"https://oman.paymob.com/api/auth/tokens", "https://oman.paymob.com/api/auth/tokens",
{ {
api_key: process.env.PAYMOB_API_KEY, api_key: process.env.PAYMOB_API_KEY,
}, },
{headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, { headers: { Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}` } },
); );
return response.data.token; return response.data.token;
}; };
const checkTransaction = async (token: string, orderID: number) => { const checkTransaction = async (token: string, orderID: number) => {
const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID });
return response.status === 200; return response.status === 200;
}; };

View File

@@ -2,6 +2,7 @@
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip"; import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useEntityPermission } from "@/hooks/useEntityPermissions";
@@ -29,6 +30,7 @@ import { useRouter } from "next/router";
import { Divider } from "primereact/divider"; import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal";
import { import {
BsCheck, BsCheck,
@@ -56,6 +58,11 @@ const expirationDateColor = (date: Date) => {
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
}; };
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
const user = req.session.user as User; const user = req.session.user as User;
@@ -102,6 +109,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
const router = useRouter(); const router = useRouter();
@@ -198,6 +207,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };
const updatePayment = () => {
if (!isAdmin(user)) return;
setIsLoading(true);
axios
.patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } })
.then(() => {
toast.success("The entity has been updated successfully!");
router.replace(router.asPath);
})
.catch((e) => {
console.error(e);
toast.error("Something went wrong!");
})
.finally(() => setIsLoading(false));
};
const editLicenses = () => { const editLicenses = () => {
if (!isAdmin(user)) return; if (!isAdmin(user)) return;
@@ -430,6 +456,36 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<span className="text-xs">Apply Change</span> <span className="text-xs">Apply Change</span>
</button> </button>
</div> </div>
<Divider />
<div className="w-full flex items-center justify-between gap-8">
<div className="w-full max-w-xl flex items-center gap-4">
<Input
name="paymentValue"
onChange={(e) => setPaymentPrice(e ? parseInt(e) : undefined)}
type="number"
defaultValue={entity.payment?.price || 0}
thin
/>
<Select
className={clsx(
"px-4 !py-2 !w-full text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none",
)}
options={CURRENCIES_OPTIONS}
value={CURRENCIES_OPTIONS.find((c) => c.value === paymentCurrency)}
onChange={(value) => setPaymentCurrency(value?.value ?? undefined)}
/>
</div>
<button
onClick={updatePayment}
disabled={!paymentPrice || paymentPrice <= 0 || !paymentCurrency}
className="flex w-fit text-nowrap items-center gap-1 px-2 py-2 border rounded-full border-mti-green bg-mti-green-light text-white hover:bg-mti-green-dark disabled:hover:bg-mti-green-light disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition ease-in-out duration-300">
<BsCheck />
<span className="text-xs">Apply Change</span>
</button>
</div>
</> </>
)} )}

View File

@@ -11,6 +11,10 @@ import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import client from "@/lib/mongodb";
import { Discount, Package } from "@/interfaces/paypal";
const db = client.db(process.env.MONGODB_DB);
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
@@ -19,17 +23,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entityIDs = mapBy(user.entities, 'id') const entityIDs = mapBy(user.entities, 'id')
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs)
const domain = user.email.split("@").pop()
const discounts = await db.collection<Discount>("discounts").find({ domain }).toArray()
const packages = await db.collection<Package>("packages").find().toArray()
return { return {
props: serialize({ user, entities }), props: serialize({ user, entities, discounts, packages }),
}; };
}, sessionOptions); }, sessionOptions);
interface Props { interface Props {
user: User, user: User,
entities: EntityWithRoles[] entities: EntityWithRoles[]
discounts: Discount[]
packages: Package[]
} }
export default function Home({ user, entities }: Props) { export default function Home(props: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
@@ -43,7 +53,8 @@ export default function Home({ user, entities }: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<PaymentDue entities={entities} user={user} reload={router.reload} />
<PaymentDue {...props} reload={router.reload} />
</> </>
); );
} }

View File

@@ -6,171 +6,171 @@ import { levelPart, listeningSection, readingPart, speakingTask, writingTask } f
export const defaultSettings = (module: Module) => { export const defaultSettings = (module: Module) => {
const baseSettings = { const baseSettings = {
category: '', category: '',
introOption: { label: 'None', value: 'None' }, introOption: { label: 'None', value: 'None' },
customIntro: '', customIntro: '',
currentIntro: '', currentIntro: '',
topic: '', topic: '',
isCategoryDropdownOpen: false, isCategoryDropdownOpen: false,
isIntroDropdownOpen: false, isIntroDropdownOpen: false,
isExerciseDropdownOpen: false, isExerciseDropdownOpen: false,
isTypeDropdownOpen: false, isTypeDropdownOpen: false,
} }
switch (module) { switch (module) {
case 'writing': case 'writing':
return { return {
...baseSettings, ...baseSettings,
writingTopic: '', writingTopic: '',
isWritingTopicOpen: false, isWritingTopicOpen: false,
isImageUploadOpen: false, isImageUploadOpen: false,
} }
case 'reading': case 'reading':
return { return {
...baseSettings, ...baseSettings,
isPassageOpen: false, isPassageOpen: false,
readingTopic: '', readingTopic: '',
isReadingTopicOpean: false, isReadingTopicOpean: false,
} }
case 'listening': case 'listening':
return { return {
...baseSettings, ...baseSettings,
isAudioContextOpen: false, isAudioContextOpen: false,
isAudioGenerationOpen: false, isAudioGenerationOpen: false,
listeningTopic: '', listeningTopic: '',
isListeningTopicOpen: false, isListeningTopicOpen: false,
} }
case 'speaking': case 'speaking':
return { return {
...baseSettings, ...baseSettings,
speakingTopic: '', speakingTopic: '',
speakingSecondTopic: '', speakingSecondTopic: '',
isSpeakingTopicOpen: false, isSpeakingTopicOpen: false,
isGenerateVideoOpen: false, isGenerateVideoOpen: false,
} }
case 'level': case 'level':
return { return {
...baseSettings, ...baseSettings,
isReadingDropdownOpen: false, isReadingDropdownOpen: false,
isWritingDropdownOpen: false, isWritingDropdownOpen: false,
isSpeakingDropdownOpen: false, isSpeakingDropdownOpen: false,
isListeningDropdownOpen: false, isListeningDropdownOpen: false,
isWritingTopicOpen: false, isWritingTopicOpen: false,
isImageUploadOpen: false, isImageUploadOpen: false,
writingTopic: '', writingTopic: '',
isPassageOpen: false, isPassageOpen: false,
readingTopic: '', readingTopic: '',
isReadingTopicOpean: false, isReadingTopicOpean: false,
isAudioContextOpen: false, isAudioContextOpen: false,
isAudioGenerationOpen: false, isAudioGenerationOpen: false,
listeningTopic: '', listeningTopic: '',
isListeningTopicOpen: false, isListeningTopicOpen: false,
speakingTopic: '', speakingTopic: '',
speakingSecondTopic: '', speakingSecondTopic: '',
isSpeakingTopicOpen: false, isSpeakingTopicOpen: false,
isGenerateVideoOpen: false, isGenerateVideoOpen: false,
} }
default: default:
return baseSettings; return baseSettings;
} }
} }
export const sectionLabels = (module: Module, levelParts?: number) => { export const sectionLabels = (module: Module, levelParts?: number) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return Array.from({ length: 3 }, (_, index) => ({ return Array.from({ length: 3 }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Passage ${index + 1}` label: `Passage ${index + 1}`
})); }));
case 'writing': case 'writing':
return [{ id: 1, label: "Task 1" }, { id: 2, label: "Task 2" }]; return [{ id: 1, label: "Task 1" }, { id: 2, label: "Task 2" }];
case 'speaking': case 'speaking':
return [{ id: 1, label: "Speaking 1" }, { id: 2, label: "Speaking 2" }, { id: 3, label: "Interactive Speaking" }]; return [{ id: 1, label: "Speaking 1" }, { id: 2, label: "Speaking 2" }, { id: 3, label: "Interactive Speaking" }];
case 'listening': case 'listening':
return Array.from({ length: 4 }, (_, index) => ({ return Array.from({ length: 4 }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Section ${index + 1}` label: `Section ${index + 1}`
})); }));
case 'level': case 'level':
return levelParts !== undefined ? return levelParts !== undefined ?
Array.from({ length: levelParts }, (_, index) => ({ Array.from({ length: levelParts }, (_, index) => ({
id: index + 1, id: index + 1,
label: `Part ${index + 1}` label: `Part ${index + 1}`
})) }))
: :
[{ id: 1, label: "Part 1" }]; [{ id: 1, label: "Part 1" }];
} }
} }
const defaultExamLabel = (module: Module) => { const defaultExamLabel = (module: Module) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return "Reading Exam"; return "Reading Exam";
case 'writing': case 'writing':
return "Writing Exam"; return "Writing Exam";
case 'speaking': case 'speaking':
return "Speaking Exam"; return "Speaking Exam";
case 'listening': case 'listening':
return "Listening Exam"; return "Listening Exam";
case 'level': case 'level':
return "Placement Test"; return "Placement Test";
} }
} }
const defaultSection = (module: Module, sectionId: number) => { const defaultSection = (module: Module, sectionId: number) => {
switch (module) { switch (module) {
case 'reading': case 'reading':
return readingPart(sectionId); return readingPart(sectionId);
case 'writing': case 'writing':
return writingTask(sectionId); return writingTask(sectionId);
case 'listening': case 'listening':
return listeningSection(sectionId) return listeningSection(sectionId)
case 'speaking': case 'speaking':
return speakingTask(sectionId) return speakingTask(sectionId)
case 'level': case 'level':
return levelPart(sectionId) return levelPart(sectionId)
} }
} }
export const defaultSectionSettings = (module: Module, sectionId: number, part?: ExamPart) => { export const defaultSectionSettings = (module: Module, sectionId: number, part?: ExamPart) => {
return { return {
sectionId: sectionId, sectionId: sectionId,
settings: defaultSettings(module), settings: defaultSettings(module),
state: part !== undefined ? part : defaultSection(module, sectionId), state: part !== undefined ? part : defaultSection(module, sectionId),
generating: undefined, generating: undefined,
genResult: undefined, genResult: undefined,
focusedExercise: undefined, focusedExercise: undefined,
expandedSubSections: [], expandedSubSections: [],
levelGenerating: [], levelGenerating: [],
levelGenResults: [], levelGenResults: [],
scriptLoading: false, scriptLoading: false,
} }
} }
const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => { const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => {
const state: ModuleState = { const state: ModuleState = {
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!, difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
isPrivate: false, isPrivate: true,
sectionLabels: sectionLabels(module), sectionLabels: sectionLabels(module),
expandedSections: [1], expandedSections: [1],
focusedSection: 1, focusedSection: 1,
sections: [defaultSectionSettings(module, 1)], sections: [defaultSectionSettings(module, 1)],
importModule: true, importModule: true,
importing: false, importing: false,
edit: [], edit: [],
}; };
if (["reading", "writing"].includes(module)) { if (["reading", "writing"].includes(module)) {
state["type"] = "general"; state["type"] = "general";
} }
return state; return state;
} }
export default defaultModuleSettings; export default defaultModuleSettings;