Merge branch 'develop'

This commit is contained in:
Tiago Ribeiro
2024-05-20 21:09:43 +01:00
14 changed files with 1787 additions and 1753 deletions

View File

@@ -86,7 +86,7 @@ export default function Navbar({user, path, navDisabled = false, focusMode = fal
{showExpirationDate() && ( {showExpirationDate() && (
<Link <Link
href={user.subscriptionExpirationDate && disablePaymentPage ? "/payment" : ""} href={!!user.subscriptionExpirationDate && !disablePaymentPage ? "/payment" : ""}
data-tip="Expiry date" data-tip="Expiry date"
className={clsx( className={clsx(
"flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none", "flex w-fit cursor-pointer justify-center rounded-full border px-6 py-2 text-sm font-normal focus:outline-none",

View File

@@ -0,0 +1,77 @@
import {PaymentIntention} from "@/interfaces/paymob";
import {DurationUnit} from "@/interfaces/paypal";
import {User} from "@/interfaces/user";
import axios from "axios";
import {useRouter} from "next/router";
import {useState} from "react";
import Button from "./Low/Button";
import Input from "./Low/Input";
import Modal from "./Modal";
interface Props {
user: User;
currency: string;
price: number;
setIsPaymentLoading: (v: boolean) => void;
duration: number;
duration_unit: DurationUnit;
onSuccess: (duration: number, duration_unit: DurationUnit) => void;
}
export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleCardPayment = async () => {
try {
setIsPaymentLoading(true);
const paymentIntention: PaymentIntention = {
amount: price * 1000,
currency: "OMR",
items: [],
payment_methods: [],
customer: {
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
extras: {
re: user.id,
},
},
billing_data: {
apartment: "N/A",
building: "N/A",
country: user.demographicInformation?.country || "N/A",
email: user.email,
first_name: user.name.split(" ")[0],
last_name: [...user.name.split(" ")].pop() || "N/A",
floor: "N/A",
phone_number: user.demographicInformation?.phone || "N/A",
state: "N/A",
street: "N/A",
},
extras: {
userID: user.id,
duration,
duration_unit,
},
};
const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention);
router.push(response.data.iframeURL);
} catch (error) {
console.error("Error starting card payment process:", error);
}
};
return (
<>
<Button isLoading={isLoading} onClick={handleCardPayment}>
Select
</Button>
</>
);
}

View File

@@ -1,4 +1,4 @@
import {useState, useMemo} from 'react'; import {useState, useMemo} from "react";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
/*fields example = [ /*fields example = [
@@ -6,43 +6,33 @@ import Input from "@/components/Low/Input";
['companyInformation', 'companyInformation', 'name'] ['companyInformation', 'companyInformation', 'name']
]*/ ]*/
const getFieldValue = (fields: string[], data: any): string => { const getFieldValue = (fields: string[], data: any): string => {
if(fields.length === 0) return data; if (fields.length === 0) return data;
const [key, ...otherFields] = fields; const [key, ...otherFields] = fields;
if(data[key]) return getFieldValue(otherFields, data[key]); if (data[key]) return getFieldValue(otherFields, data[key]);
return data; return data;
} };
export const useListSearch = (fields: string[][], rows: any[]) => { export function useListSearch<T>(fields: string[][], rows: T[]) {
const [text, setText] = useState(''); const [text, setText] = useState("");
const renderSearch = () => ( const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
<Input
label="Search"
type="text"
name="search"
onChange={setText}
placeholder="Enter search text"
value={text}
/>
)
const updatedRows = useMemo(() => { const updatedRows = useMemo(() => {
const searchText = text.toLowerCase(); const searchText = text.toLowerCase();
return rows.filter((row) => { return rows.filter((row) => {
return fields.some((fieldsKeys) => { return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row); const value = getFieldValue(fieldsKeys, row);
if(typeof value === 'string') { if (typeof value === "string") {
return value.toLowerCase().includes(searchText); return value.toLowerCase().includes(searchText);
} }
}) });
}) });
}, [fields, rows, text]) }, [fields, rows, text]);
return { return {
rows: updatedRows, rows: updatedRows,
renderSearch, renderSearch,
} };
} }

118
src/interfaces/paymob.ts Normal file
View File

@@ -0,0 +1,118 @@
export interface PaymentIntention {
amount: number;
currency: string;
payment_methods: number[];
items: any[];
billing_data: BillingData;
customer: Customer;
extras: IntentionExtras;
}
interface BillingData {
apartment: string;
first_name: string;
last_name: string;
street: string;
building: string;
phone_number: string;
country: string;
email: string;
floor: string;
state: string;
}
interface Customer {
first_name: string;
last_name: string;
email: string;
extras: IntentionExtras;
}
type IntentionExtras = {[key: string]: string | number};
export interface IntentionResult {
payment_keys: PaymentKeysItem[];
id: string;
intention_detail: IntentionDetail;
client_secret: string;
payment_methods: PaymentMethodsItem[];
special_reference: null;
extras: Extras;
confirmed: boolean;
status: string;
created: string;
card_detail: null;
object: string;
}
interface PaymentKeysItem {
integration: number;
key: string;
gateway_type: string;
iframe_id: null;
}
interface IntentionDetail {
amount: number;
items: ItemsItem[];
currency: string;
}
interface ItemsItem {
name: string;
amount: number;
description: string;
quantity: number;
}
interface PaymentMethodsItem {
integration_id: number;
alias: null;
name: null;
method_type: string;
currency: string;
live: boolean;
use_cvc_with_moto: boolean;
}
interface Extras {
creation_extras: IntentionExtras;
confirmation_extras: null;
}
export interface TransactionResult {
paymob_request_id: null;
intention: IntentionResult;
hmac: string;
transaction: Transaction;
}
interface Transaction {
amount_cents: number;
created_at: string;
currency: string;
error_occured: boolean;
has_parent_transaction: boolean;
id: number;
integration_id: number;
is_3d_secure: boolean;
is_auth: boolean;
is_capture: boolean;
is_refunded: boolean;
is_standalone_payment: boolean;
is_voided: boolean;
order: Order;
owner: number;
pending: boolean;
source_data: Source_data;
success: boolean;
receipt: string;
}
interface Order {
id: number;
}
interface Source_data {
pan: string;
sub_type: string;
type: string;
}

View File

@@ -40,7 +40,7 @@ function PackageCreator({pack, onClose}: {pack?: Package; onClose: () => void})
const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months"); const [unit, setUnit] = useState<DurationUnit>(pack?.duration_unit || "months");
const [price, setPrice] = useState(pack?.price || 0); const [price, setPrice] = useState(pack?.price || 0);
const [currency, setCurrency] = useState<string>(pack?.currency || "EUR"); const [currency, setCurrency] = useState<string>(pack?.currency || "OMR");
const submit = () => { const submit = () => {
(pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", { (pack ? axios.patch : axios.post)(pack ? `/api/packages/${pack.id}` : "/api/packages", {

View File

@@ -16,49 +16,25 @@ import {countries, TCountries} from "countries-list";
import countryCodes from "country-codes-list"; import countryCodes from "country-codes-list";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import UserCard from "@/components/UserCard"; import UserCard from "@/components/UserCard";
import {isAgentUser, USER_TYPE_LABELS} from "@/resources/user"; import {getUserCompanyName, isAgentUser, USER_TYPE_LABELS} from "@/resources/user";
import useFilterStore from "@/stores/listFilterStore"; import useFilterStore from "@/stores/listFilterStore";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {isCorporateUser} from "@/resources/user"; import {isCorporateUser} from "@/resources/user";
import {useListSearch} from "@/hooks/useListSearch"; import {useListSearch} from "@/hooks/useListSearch";
import {getUserCorporate} from "@/utils/groups"; import {getUserCorporate} from "@/utils/groups";
import {asyncSorter} from "@/utils"; import {asyncSorter} from "@/utils";
import {exportListToExcel, UserListRow} from "@/utils/users";
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]]; const searchFields = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]];
const getCompanyName = async (user: User) => { const CompanyNameCell = ({users, user, groups}: {user: User; users: User[]; groups: Group[]}) => {
if (isCorporateUser(user)) {
return user.corporateInformation?.companyInformation?.name;
}
if (isAgentUser(user)) {
return user.agentInformation.companyName;
}
if (user.type === "teacher" || user.type === "student") {
const userCorporate = await getUserCorporate(user.id);
return userCorporate?.corporateInformation?.companyInformation.name || "";
}
return "";
};
const CompanyNameCell = ({users, user, groups}: {user: User, users: User[], groups: Group[]}) => {
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name) const name = getUserCompanyName(user, users, groups);
if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name) setCompanyName(name);
const belongingGroups = groups.filter((x) => x.participants.includes(user.id))
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x))
if (belongingGroupsAdmins.length === 0) return setCompanyName("")
const admin = (belongingGroupsAdmins[0] as CorporateUser)
setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name)
}, [user, users, groups]); }, [user, users, groups]);
return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>; return isLoading ? <span className="animate-pulse">Loading...</span> : <>{companyName}</>;
@@ -501,8 +477,8 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
} }
if (sorter === "companyName" || sorter === reverseString("companyName")) { if (sorter === "companyName" || sorter === reverseString("companyName")) {
const aCorporateName = await getCompanyName(a); const aCorporateName = getUserCompanyName(a, users, groups);
const bCorporateName = await getCompanyName(b); const bCorporateName = getUserCompanyName(b, users, groups);
if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1; if (!aCorporateName && bCorporateName) return sorter === "companyName" ? -1 : 1;
if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1; if (aCorporateName && !bCorporateName) return sorter === "companyName" ? 1 : -1;
if (!aCorporateName && !bCorporateName) return 0; if (!aCorporateName && !bCorporateName) return 0;
@@ -513,7 +489,7 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}; };
const {rows: filteredRows, renderSearch} = useListSearch(searchFields, displayUsers); const {rows: filteredRows, renderSearch} = useListSearch<User>(searchFields, displayUsers);
const table = useReactTable({ const table = useReactTable({
data: filteredRows, data: filteredRows,
@@ -521,6 +497,18 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const downloadExcel = () => {
const csv = exportListToExcel(filteredRows, users, groups);
const element = document.createElement("a");
const file = new Blob([csv], {type: "text/plain"});
element.href = URL.createObjectURL(file);
element.download = "users.xlsx";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
return ( return (
<div className="w-full"> <div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}> <Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
@@ -600,7 +588,12 @@ export default function UserList({user, filters = []}: {user: User; filters?: ((
</> </>
</Modal> </Modal>
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end">
{renderSearch()} {renderSearch()}
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={downloadExcel}>
Download List
</Button>
</div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (

View File

@@ -1,22 +1,19 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import PayPalPayment from "@/components/PayPalPayment";
import useGroups from "@/hooks/useGroups"; import useGroups from "@/hooks/useGroups";
import usePackages from "@/hooks/usePackages"; 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} from "lodash";
import { useEffect, useState } from "react"; import {useEffect, useState} from "react";
import getSymbolFromCurrency from "currency-symbol-map";
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";
import { useRouter } from "next/router"; import {useRouter} from "next/router";
import { PayPalScriptProvider } from "@paypal/react-paypal-js"; import {ToastContainer} from "react-toastify";
import { usePaypalTracking } from "@/hooks/usePaypalTracking";
import { ToastContainer } from "react-toastify";
import useDiscounts from "@/hooks/useDiscounts"; import useDiscounts from "@/hooks/useDiscounts";
import PaymobPayment from "@/components/PaymobPayment";
interface Props { interface Props {
user: User; user: User;
@@ -25,37 +22,23 @@ interface Props {
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
user,
hasExpired = false,
clientID,
reload,
}: Props) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [appliedDiscount, setAppliedDiscount] = useState(0); const [appliedDiscount, setAppliedDiscount] = useState(0);
const router = useRouter(); const router = useRouter();
const { packages } = usePackages(); const {packages} = usePackages();
const { discounts } = useDiscounts(); const {discounts} = useDiscounts();
const { users } = useUsers(); const {users} = useUsers();
const { groups } = useGroups(); const {groups} = useGroups();
const { const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user?.id});
invites,
isLoading: isInvitesLoading,
reload: reloadInvites,
} = useInvites({ to: user?.id });
const trackingId = usePaypalTracking();
useEffect(() => { useEffect(() => {
const userDiscounts = discounts.filter((x) => const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`));
user.email.endsWith(`@${x.domain}`),
);
if (userDiscounts.length === 0) return; if (userDiscounts.length === 0) return;
const biggestDiscount = [...userDiscounts] const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift();
.sort((a, b) => b.percentage - a.percentage)
.shift();
if (!biggestDiscount) return; if (!biggestDiscount) return;
setAppliedDiscount(biggestDiscount.percentage); setAppliedDiscount(biggestDiscount.percentage);
@@ -68,9 +51,7 @@ export default function PaymentDue({
if (userGroups.length === 0) return true; if (userGroups.length === 0) return true;
const userGroupsAdminTypes = userGroups const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t);
.map((g) => users?.find((u) => u.id === g.admin)?.type)
.filter((t) => !!t);
return userGroupsAdminTypes.every((t) => t !== "corporate"); return userGroupsAdminTypes.every((t) => t !== "corporate");
}; };
@@ -79,11 +60,15 @@ export default function PaymentDue({
<ToastContainer /> <ToastContainer />
{isLoading && ( {isLoading && (
<div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60"> <div className="absolute left-0 top-0 z-[999] h-screen w-screen overflow-hidden bg-black/60">
<div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 animate-pulse flex-col items-center gap-8 text-white"> <div className="absolute left-1/2 top-1/2 flex h-fit w-fit -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-8 text-white">
<span className={clsx("loading loading-infinity w-48")} /> <span className={clsx("loading loading-infinity w-48 animate-pulse")} />
<span className={clsx("text-2xl font-bold")}> <span className={clsx("text-2xl font-bold animate-pulse")}>Completing your payment...</span>
Completing your payment... <span>If you canceled your payment or it failed, please click the button below to restart</span>
</span> <button
onClick={() => setIsLoading(false)}
className="border border-white rounded-full px-4 py-2 hover:bg-white/80 hover:text-black cursor-pointer transition ease-in-out duration-300">
Cancel Payment
</button>
</div> </div>
</div> </div>
)} )}
@@ -94,17 +79,9 @@ export default function PaymentDue({
<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"> <BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
Invites
</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isInvitesLoading && "animate-spin",
)}
/>
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
@@ -124,145 +101,84 @@ export default function PaymentDue({
)} )}
<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 && ( {hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
<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 To add to your use of EnCoach, please purchase one of the time packages available below:
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">
<PayPalScriptProvider
options={{
clientId: clientID,
currency: "USD",
intent: "capture",
commit: true,
}}
>
{packages.map((p) => ( {packages.map((p) => (
<div <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
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"> <div className="mb-2 flex flex-col items-start">
<img <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
src="/logo_title.png"
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
EnCoach - {p.duration}{" "} EnCoach - {p.duration}{" "}
{capitalize( {capitalize(
p.duration === 1 p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
? 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">
{!appliedDiscount && ( {!appliedDiscount && (
<span className="text-2xl"> <span className="text-2xl">
{p.price} {p.price} {p.currency}
{getSymbolFromCurrency(p.currency)}
</span> </span>
)} )}
{appliedDiscount && ( {appliedDiscount && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl line-through"> <span className="text-2xl line-through">
{p.price} {p.price} {p.currency}
{getSymbolFromCurrency(p.currency)}
</span> </span>
<span className="text-2xl text-mti-red-light"> <span className="text-2xl text-mti-red-light">
{( {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
p.price -
p.price * (appliedDiscount / 100)
).toFixed(2)}
{getSymbolFromCurrency(p.currency)}
</span> </span>
</div> </div>
)} )}
<PayPalPayment <PaymobPayment
key={clientID} key={clientID}
clientID={clientID} user={user}
setIsLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
onSuccess={() => { onSuccess={() => {
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
trackingId={trackingId}
currency={p.currency} currency={p.currency}
duration={p.duration} duration={p.duration}
duration_unit={p.duration_unit} duration_unit={p.duration_unit}
price={ price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
+(
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>- Train your abilities for the IELTS exam</li> <li>- Train your abilities for the IELTS exam</li>
<li> <li>- Gain insights into your weaknesses and strengths</li>
- Gain insights into your weaknesses and strengths <li>- Allow yourself to correctly prepare for the exam</li>
</li>
<li>
- Allow yourself to correctly prepare for the exam
</li>
</ul> </ul>
</div> </div>
</div> </div>
))} ))}
</PayPalScriptProvider>
</div> </div>
</div> </div>
)} )}
{!isIndividual() && {!isIndividual() && user.type === "corporate" && user?.corporateInformation.payment && (
user.type === "corporate" &&
user?.corporateInformation.payment && (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach and that of your students and To add to your use of EnCoach and that of your students and teachers, please pay your designated package below:
teachers, please pay your designated package below:
</span> </span>
<div <div className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
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 <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
src="/logo_title.png" <span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
alt="EnCoach's Logo"
className="w-32"
/>
<span className="text-xl font-semibold">
EnCoach - {user.corporateInformation?.monthlyDuration}{" "}
Months
</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"> <span className="text-2xl">
{user.corporateInformation.payment.value} {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
{getSymbolFromCurrency(
user.corporateInformation.payment.currency,
)}
</span> </span>
<PayPalPayment <PaymobPayment
key={clientID} key={clientID}
clientID={clientID} user={user}
setIsLoading={setIsLoading} setIsPaymentLoading={setIsLoading}
currency={user.corporateInformation.payment.currency} currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value} price={user.corporateInformation.payment.value}
duration={user.corporateInformation.monthlyDuration} duration={user.corporateInformation.monthlyDuration}
@@ -271,26 +187,17 @@ export default function PaymentDue({
setIsLoading(false); setIsLoading(false);
setTimeout(reload, 500); setTimeout(reload, 500);
}} }}
loadScript
trackingId={trackingId}
/> />
</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>
- Allow a total of{" "} - Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to
{ use EnCoach
user.corporateInformation.companyInformation
.userAmount
}{" "}
students and teachers to use EnCoach
</li> </li>
<li>- Train their abilities for the IELTS exam</li> <li>- Train their abilities for the IELTS exam</li>
<li> <li>- Gain insights into your students&apos; weaknesses and strengths</li>
- Gain insights into your students&apos; weaknesses
and strengths
</li>
<li>- Allow them to correctly prepare for the exam</li> <li>- Allow them to correctly prepare for the exam</li>
</ul> </ul>
</div> </div>
@@ -300,27 +207,22 @@ export default function PaymentDue({
{!isIndividual() && user.type !== "corporate" && ( {!isIndividual() && user.type !== "corporate" && (
<div className="flex flex-col items-center"> <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 You are not the person in charge of your time credits, please contact your administrator about this situation.
contact your administrator about this situation.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
If you believe this to be a mistake, please contact the If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your
platform&apos;s administration, thank you for your patience. patience.
</span> </span>
</div> </div>
)} )}
{!isIndividual() && {!isIndividual() && user.type === "corporate" && !user.corporateInformation.payment && (
user.type === "corporate" &&
!user.corporateInformation.payment && (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="max-w-lg"> <span className="max-w-lg">
An admin nor your agent have yet set the price intended to An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you
your requirements in terms of the amount of users you desire desire and your expected monthly duration.
and your expected monthly duration.
</span> </span>
<span className="max-w-lg"> <span className="max-w-lg">
Please try again later or contact your agent or an admin, Please try again later or contact your agent or an admin, thank you for your patience.
thank you for your patience.
</span> </span>
</div> </div>
)} )}

View File

@@ -0,0 +1,52 @@
// 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 {Payment} from "@/interfaces/paypal";
import {v4} from "uuid";
import ShortUniqueId from "short-unique-id";
import axios from "axios";
import {IntentionResult, PaymentIntention} from "@/interfaces/paymob";
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, "payments"));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const intention = req.body as PaymentIntention;
const response = await axios.post<IntentionResult>(
"https://oman.paymob.com/v1/intention/",
{...intention, payment_methods: [parseInt(process.env.PAYMOB_INTEGRATION_ID || "0")], items: []},
{headers: {Authorization: `Token ${process.env.PAYMOB_SECRET_KEY}`}},
);
const intentionResult = response.data;
res.status(200).json({
iframeURL: `https://oman.paymob.com/unifiedcheckout/?publicKey=${process.env.PAYMOB_PUBLIC_KEY}&clientSecret=${intentionResult.client_secret}`,
});
}

View File

@@ -0,0 +1,95 @@
// 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, getDoc, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Group, User} from "@/interfaces/user";
import {DurationUnit, Package, Payment} from "@/interfaces/paypal";
import {v4} from "uuid";
import ShortUniqueId from "short-unique-id";
import axios from "axios";
import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob";
import moment from "moment";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") await post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const transactionResult = req.body as TransactionResult;
const authToken = await authenticatePaymob();
if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false});
if (!transactionResult.transaction.success) return res.status(200).json({ok: false});
const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as {
userID: string;
duration: number;
duration_unit: DurationUnit;
};
const userSnapshot = await getDoc(doc(db, "users", userID as string));
if (!userSnapshot.exists() || !duration || !duration_unit) return res.status(404).json({ok: false});
const user = {...userSnapshot.data(), id: userSnapshot.id} as User;
const subscriptionExpirationDate = user.subscriptionExpirationDate;
if (!subscriptionExpirationDate) return res.status(200).json({ok: false});
const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment();
const updatedSubscriptionExpirationDate = moment(initialDate).add(duration, duration_unit).toISOString();
await setDoc(userSnapshot.ref, {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
await setDoc(doc(db, "paypalpayments", v4()), {
createdAt: new Date().toISOString(),
currency: transactionResult.transaction.currency,
orderId: transactionResult.transaction.id,
status: "COMPLETED",
subscriptionDuration: duration,
subscriptionDurationUnit: duration_unit,
subscriptionExpirationDate: updatedSubscriptionExpirationDate,
userId: userID,
value: transactionResult.transaction.amount_cents / 1000,
});
if (user.type === "corporate") {
const groupsSnapshot = await getDocs(query(collection(db, "groups"), where("admin", "==", user.id)));
const groups = groupsSnapshot.docs.map((g) => ({...g.data(), id: g.id})) as Group[];
const participants = (await Promise.all(
groups.flatMap((x) => x.participants).map(async (x) => ({...(await getDoc(doc(db, "users", x))).data(), id: x})),
)) as User[];
const sameExpiryDateParticipants = participants.filter((x) => x.subscriptionExpirationDate === subscriptionExpirationDate);
for (const participant of sameExpiryDateParticipants) {
await setDoc(doc(db, "users", participant.id), {subscriptionExpirationDate: updatedSubscriptionExpirationDate}, {merge: true});
}
}
res.status(200).json({
ok: true,
});
}
const authenticatePaymob = async () => {
const response = await axios.post<{token: string}>(
"https://oman.paymob.com/api/auth/tokens",
{
api_key: process.env.PAYMOB_API_KEY,
},
{headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}},
);
return response.data.token;
};
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});
return response.status === 200;
};

File diff suppressed because it is too large Load Diff

View File

@@ -95,4 +95,8 @@ export const CURRENCIES: {label: string; currency: string}[] = [
label: "United States dollar", label: "United States dollar",
currency: "USD", currency: "USD",
}, },
{
label: "Omani rial",
currency: "OMR",
},
]; ];

View File

@@ -1,4 +1,4 @@
import {Type, User, CorporateUser, AgentUser} from "@/interfaces/user"; import {Type, User, CorporateUser, AgentUser, Group} from "@/interfaces/user";
export const USER_TYPE_LABELS: {[key in Type]: string} = { export const USER_TYPE_LABELS: {[key in Type]: string} = {
student: "Student", student: "Student",
@@ -16,3 +16,16 @@ export function isCorporateUser(user: User): user is CorporateUser {
export function isAgentUser(user: User): user is AgentUser { export function isAgentUser(user: User): user is AgentUser {
return (user as AgentUser).agentInformation !== undefined; return (user as AgentUser).agentInformation !== undefined;
} }
export function getUserCompanyName(user: User, users: User[], groups: Group[]) {
if (isCorporateUser(user)) return user.corporateInformation?.companyInformation?.name || user.name;
if (isAgentUser(user)) return user.agentInformation?.companyName || user.name;
const belongingGroups = groups.filter((x) => x.participants.includes(user.id));
const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x));
if (belongingGroupsAdmins.length === 0) return "";
const admin = belongingGroupsAdmins[0] as CorporateUser;
return admin.corporateInformation?.companyInformation.name || admin.name;
}

36
src/utils/users.ts Normal file
View File

@@ -0,0 +1,36 @@
import {Group, User} from "@/interfaces/user";
import {getUserCompanyName, USER_TYPE_LABELS} from "@/resources/user";
import {capitalize} from "lodash";
import moment from "moment";
export interface UserListRow {
name: string;
email: string;
type: string;
companyName: string;
expiryDate: string;
verified: string;
country: string;
phone: string;
employmentPosition: string;
gender: string;
}
export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group[]) => {
const rows: UserListRow[] = rowUsers.map((user) => ({
name: user.name,
email: user.email,
type: USER_TYPE_LABELS[user.type],
companyName: getUserCompanyName(user, users, groups),
expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited",
country: user.demographicInformation?.country || "N/A",
phone: user.demographicInformation?.phone || "N/A",
employmentPosition: (user.type === "corporate" ? user.demographicInformation?.position : user.demographicInformation?.employment) || "N/A",
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE",
}));
const header = "Name,Email,Type,Company Name,Expiry Date,Country,Phone,Employment/Position,Gender,Verification";
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n");
return `${header}\n${rowsString}`;
};