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

@@ -35,7 +35,7 @@ export default function Layout({
return (
<main className={clsx("w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative")}>
<ToastContainer />
{!hideSidebar && (
{!hideSidebar && user && (
<Navbar
path={router.pathname}
user={user}
@@ -45,7 +45,7 @@ export default function Layout({
/>
)}
<div className={clsx("h-full w-full flex gap-2")}>
{!hideSidebar && (
{!hideSidebar && user && (
<Sidebar
path={router.pathname}
navDisabled={navDisabled}

View File

@@ -13,6 +13,7 @@ interface Props {
disabled?: boolean;
max?: number;
min?: number;
thin?: boolean
name: string;
onChange: (value: string) => void;
}
@@ -29,6 +30,7 @@ export default function Input({
className,
roundness = "full",
disabled = false,
thin = false,
min,
onChange,
}: Props) {
@@ -95,9 +97,10 @@ export default function Input({
min={type === "number" ? (min ?? 0) : undefined}
placeholder={placeholder}
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",
roundness === "full" ? "rounded-full" : "rounded-xl",
thin ? 'py-4' : 'py-6'
)}
required={required}
defaultValue={defaultValue}

View File

@@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa
{
module: "reading",
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",
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",
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",
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",
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,3 +1,4 @@
import { Entity } from "@/interfaces/entity";
import { PaymentIntention } from "@/interfaces/paymob";
import { DurationUnit } from "@/interfaces/paypal";
import { User } from "@/interfaces/user";
@@ -10,6 +11,7 @@ import Modal from "./Modal";
interface Props {
user: User;
entity?: Entity
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
@@ -18,7 +20,7 @@ interface Props {
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 router = useRouter();
@@ -56,6 +58,7 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc
userID: user.id,
duration,
duration_unit,
entity: entity?.id
},
};

View File

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

View File

@@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages";
import useUsers from "@/hooks/useUsers";
import { User } from "@/interfaces/user";
import clsx from "clsx";
import { capitalize } from "lodash";
import { useEffect, useState } from "react";
import { capitalize, sortBy } from "lodash";
import { useEffect, useMemo, useState } from "react";
import useInvites from "@/hooks/useInvites";
import { BsArrowRepeat } from "react-icons/bs";
import InviteCard from "@/components/Medium/InviteCard";
@@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment";
import moment from "moment";
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 {
user: User;
user: User
discounts: Discount[]
packages: Package[]
entities: EntityWithRoles[]
hasExpired?: boolean;
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 [appliedDiscount, setAppliedDiscount] = useState(0);
const [entity, setEntity] = useState<EntityWithRoles>()
const router = useRouter();
const { packages } = usePackages();
const { discounts } = useDiscounts();
const { users } = useUsers();
const { groups } = useGroups({});
const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id });
useEffect(() => {
const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`));
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;
const isIndividual = useMemo(() => {
if (isAdmin(user)) 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);
return userGroupsAdminTypes.every((t) => t !== "corporate");
};
const appliedDiscount = useMemo(() => {
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 (
<>
@@ -74,7 +78,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{user ? (
<Layout user={user} navDisabled={hasExpired}>
{invites.length > 0 && (
<section className="flex flex-col gap-1 md:gap-3">
@@ -104,7 +107,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
<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>}
{isIndividual() && (
{isIndividual && (
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
<span className="max-w-lg">
To add to your use of EnCoach, please purchase one of the time packages available below:
@@ -162,10 +165,20 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
user?.corporateInformation.payment && (
<div className="flex flex-col items-center">
{!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">
To add to your use of EnCoach and that of your students and teachers, please pay your designated package
below:
@@ -179,13 +192,14 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-2xl">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
{entity.payment.price} {entity.payment.currency}
</span>
<PaymobPayment
user={user}
setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value}
entity={entity}
currency={entity.payment.currency}
price={entity.payment.price}
duration={12}
duration_unit="months"
onSuccess={() => {
@@ -198,7 +212,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
<span>This includes:</span>
<ul className="flex flex-col items-start text-sm">
<li>
- Allow a total of 0 students and teachers to use EnCoach
- 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>
@@ -208,7 +222,7 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</div>
</div>
)}
{!isIndividual() && !(user?.type === "corporate" || user?.type === "mastercorporate") && (
{!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.
@@ -219,10 +233,19 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
</span>
</div>
)}
{!isIndividual() &&
(user?.type === "corporate" || user?.type === "mastercorporate") &&
!user.corporateInformation.payment && (
<div className="flex flex-col items-center">
{!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.
@@ -234,9 +257,6 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)}
</div>
</Layout>
) : (
<div />
)}
</>
);
}

View File

@@ -68,6 +68,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
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) {
const entity = await getEntity(id)
const result = await db.collection<Entity>("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } });

View File

@@ -10,6 +10,10 @@ import axios from "axios";
import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob";
import moment from "moment";
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);
@@ -25,10 +29,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
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 });
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;
duration: number;
duration_unit: DurationUnit;
entity: string
};
const user = await db.collection("users").findOne<User>({ id: userID as string });
@@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
value: transactionResult.transaction.amount_cents / 1000,
});
if (user.type === "corporate") {
const groups = await db.collection("groups").find<Group>({ admin: user.id }).toArray();
if (entityID) {
const entity = await getEntity(entityID)
await db.collection<Entity>("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } });
const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})),
)) as User[];
const sameExpiryDateParticipants = participants.filter(
(x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled",
);
const users = await getEntityUsers(entityID, 0, {
subscriptionExpirationDate: entity?.expiryDate,
$and: [
{ type: { $ne: "admin" } },
{ type: { $ne: "developer" } },
]
})
for (const participant of sameExpiryDateParticipants) {
await db.collection("users").updateOne(
{ id: participant.id },
{ $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} }
);
}
await db.collection<User>("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } })
}
res.status(200).json({

View File

@@ -2,6 +2,7 @@
import CardList from "@/components/High/CardList";
import Layout from "@/components/High/Layout";
import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import Checkbox from "@/components/Low/Checkbox";
import Tooltip from "@/components/Low/Tooltip";
import { useEntityPermission } from "@/hooks/useEntityPermissions";
@@ -29,6 +30,7 @@ import { useRouter } from "next/router";
import { Divider } from "primereact/divider";
import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker";
import { CURRENCIES } from "@/resources/paypal";
import {
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";
};
const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({
value: currency,
label,
}));
export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => {
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 [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [expiryDate, setExpiryDate] = useState(entity?.expiryDate)
const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price)
const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency)
const router = useRouter();
@@ -198,6 +207,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
.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 = () => {
if (!isAdmin(user)) return;
@@ -430,6 +456,36 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
<span className="text-xs">Apply Change</span>
</button>
</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 { EntityWithRoles } from "@/interfaces/entity";
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 }) => {
const user = await requestUser(req, res)
@@ -19,17 +23,23 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const entityIDs = mapBy(user.entities, 'id')
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 {
props: serialize({ user, entities }),
props: serialize({ user, entities, discounts, packages }),
};
}, sessionOptions);
interface Props {
user: User,
entities: EntityWithRoles[]
discounts: Discount[]
packages: Package[]
}
export default function Home({ user, entities }: Props) {
export default function Home(props: Props) {
const router = useRouter();
return (
@@ -43,7 +53,8 @@ export default function Home({ user, entities }: Props) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<PaymentDue entities={entities} user={user} reload={router.reload} />
<PaymentDue {...props} reload={router.reload} />
</>
);
}

View File

@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState =>
examLabel: defaultExamLabel(module),
minTimer,
difficulty: sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!,
isPrivate: false,
isPrivate: true,
sectionLabels: sectionLabels(module),
expandedSections: [1],
focusedSection: 1,