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

@@ -98,4 +98,4 @@
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"types/": "paypal/react-paypal-js" "types/": "paypal/react-paypal-js"
} }
} }

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 function useListSearch<T>(fields: string[][], rows: T[]) {
const [text, setText] = useState("");
const renderSearch = () => <Input label="Search" type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />;
const updatedRows = useMemo(() => {
const searchText = text.toLowerCase();
return rows.filter((row) => {
return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row);
if (typeof value === "string") {
return value.toLowerCase().includes(searchText);
}
});
});
}, [fields, rows, text]);
return {
rows: updatedRows,
renderSearch,
};
} }
export const useListSearch = (fields: string[][], rows: any[]) => {
const [text, setText] = useState('');
const renderSearch = () => (
<Input
label="Search"
type="text"
name="search"
onChange={setText}
placeholder="Enter search text"
value={text}
/>
)
const updatedRows = useMemo(() => {
const searchText = text.toLowerCase();
return rows.filter((row) => {
return fields.some((fieldsKeys) => {
const value = getFieldValue(fieldsKeys, row);
if(typeof value === 'string') {
return value.toLowerCase().includes(searchText);
}
})
})
}, [fields, rows, text])
return {
rows: updatedRows,
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">
{renderSearch()} <div className="w-full flex gap-2 items-end">
{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,334 +1,236 @@
/* 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;
hasExpired?: boolean; hasExpired?: boolean;
clientID: string; clientID: string;
reload: () => void; reload: () => void;
} }
export default function PaymentDue({ export default function PaymentDue({user, hasExpired = false, clientID, reload}: Props) {
user, const [isLoading, setIsLoading] = useState(false);
hasExpired = false, const [appliedDiscount, setAppliedDiscount] = useState(0);
clientID,
reload,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
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) if (!biggestDiscount) return;
.shift();
if (!biggestDiscount) return;
setAppliedDiscount(biggestDiscount.percentage); setAppliedDiscount(biggestDiscount.percentage);
}, [discounts, user]); }, [discounts, user]);
const isIndividual = () => { const isIndividual = () => {
if (user?.type === "developer") return true; 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)); const userGroups = groups.filter((g) => g.participants.includes(user?.id));
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) return userGroupsAdminTypes.every((t) => t !== "corporate");
.filter((t) => !!t); };
return userGroupsAdminTypes.every((t) => t !== "corporate");
};
return ( return (
<> <>
<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
</div> onClick={() => setIsLoading(false)}
</div> 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
{user ? ( </button>
<Layout user={user} navDisabled={hasExpired}> </div>
{invites.length > 0 && ( </div>
<section className="flex flex-col gap-1 md:gap-3"> )}
<div className="flex items-center gap-4"> {user ? (
<div <Layout user={user} navDisabled={hasExpired}>
onClick={reloadInvites} {invites.length > 0 && (
className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out" <section className="flex flex-col gap-1 md:gap-3">
> <div className="flex items-center gap-4">
<span className="text-mti-black text-lg font-bold"> <div
Invites onClick={reloadInvites}
</span> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out">
<BsArrowRepeat <span className="text-mti-black text-lg font-bold">Invites</span>
className={clsx( <BsArrowRepeat className={clsx("text-xl", isInvitesLoading && "animate-spin")} />
"text-xl", </div>
isInvitesLoading && "animate-spin", </div>
)} <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
/> {invites.map((invite) => (
</div> <InviteCard
</div> key={invite.id}
<span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll"> invite={invite}
{invites.map((invite) => ( users={users}
<InviteCard reload={() => {
key={invite.id} reloadInvites();
invite={invite} router.reload();
users={users} }}
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 && ( {hasExpired && <span className="text-lg font-bold">You do not have time credits for your account type!</span>}
<span className="text-lg font-bold"> {isIndividual() && (
You do not have time credits for your account type! <div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll">
</span> <span className="max-w-lg">
)} To add to your use of EnCoach, please purchase one of the time packages available below:
{isIndividual() && ( </span>
<div className="scrollbar-hide flex w-full flex-col items-center gap-12 overflow-x-scroll"> <div className="flex w-full flex-wrap justify-center gap-8">
<span className="max-w-lg"> {packages.map((p) => (
To add to your use of EnCoach, please purchase one of the time <div key={p.id} className={clsx("flex flex-col items-start gap-6 rounded-xl bg-white p-4")}>
packages available below: <div className="mb-2 flex flex-col items-start">
</span> <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
<div className="flex w-full flex-wrap justify-center gap-8"> <span className="text-xl font-semibold">
<PayPalScriptProvider EnCoach - {p.duration}{" "}
options={{ {capitalize(
clientId: clientID, p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit,
currency: "USD", )}
intent: "capture", </span>
commit: true, </div>
}} <div className="flex w-full flex-col items-start gap-2">
> {!appliedDiscount && (
{packages.map((p) => ( <span className="text-2xl">
<div {p.price} {p.currency}
key={p.id} </span>
className={clsx( )}
"flex flex-col items-start gap-6 rounded-xl bg-white p-4", {appliedDiscount && (
)} <div className="flex items-center gap-2">
> <span className="text-2xl line-through">
<div className="mb-2 flex flex-col items-start"> {p.price} {p.currency}
<img </span>
src="/logo_title.png" <span className="text-2xl text-mti-red-light">
alt="EnCoach's Logo" {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency}
className="w-32" </span>
/> </div>
<span className="text-xl font-semibold"> )}
EnCoach - {p.duration}{" "} <PaymobPayment
{capitalize( key={clientID}
p.duration === 1 user={user}
? p.duration_unit.slice( setIsPaymentLoading={setIsLoading}
0, onSuccess={() => {
p.duration_unit.length - 1, setTimeout(reload, 500);
) }}
: p.duration_unit, currency={p.currency}
)} duration={p.duration}
</span> duration_unit={p.duration_unit}
</div> price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)}
<div className="flex w-full flex-col items-start gap-2"> />
{!appliedDiscount && ( </div>
<span className="text-2xl"> <div className="flex flex-col items-start gap-1">
{p.price} <span>This includes:</span>
{getSymbolFromCurrency(p.currency)} <ul className="flex flex-col items-start text-sm">
</span> <li>- Train your abilities for the IELTS exam</li>
)} <li>- Gain insights into your weaknesses and strengths</li>
{appliedDiscount && ( <li>- Allow yourself to correctly prepare for the exam</li>
<div className="flex items-center gap-2"> </ul>
<span className="text-2xl line-through"> </div>
{p.price} </div>
{getSymbolFromCurrency(p.currency)} ))}
</span> </div>
<span className="text-2xl text-mti-red-light"> </div>
{( )}
p.price - {!isIndividual() && user.type === "corporate" && user?.corporateInformation.payment && (
p.price * (appliedDiscount / 100) <div className="flex flex-col items-center">
).toFixed(2)} <span className="max-w-lg">
{getSymbolFromCurrency(p.currency)} To add to your use of EnCoach and that of your students and 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")}>
)} <div className="mb-2 flex flex-col items-start">
<PayPalPayment <img src="/logo_title.png" alt="EnCoach's Logo" className="w-32" />
key={clientID} <span className="text-xl font-semibold">EnCoach - {user.corporateInformation?.monthlyDuration} Months</span>
clientID={clientID} </div>
setIsLoading={setIsLoading} <div className="flex w-full flex-col items-start gap-2">
onSuccess={() => { <span className="text-2xl">
setTimeout(reload, 500); {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency}
}} </span>
trackingId={trackingId} <PaymobPayment
currency={p.currency} key={clientID}
duration={p.duration} user={user}
duration_unit={p.duration_unit} setIsPaymentLoading={setIsLoading}
price={ currency={user.corporateInformation.payment.currency}
+( price={user.corporateInformation.payment.value}
p.price - duration={user.corporateInformation.monthlyDuration}
p.price * (appliedDiscount / 100) duration_unit="months"
).toFixed(2) onSuccess={() => {
} setIsLoading(false);
/> setTimeout(reload, 500);
</div> }}
<div className="flex flex-col items-start gap-1"> />
<span>This includes:</span> </div>
<ul className="flex flex-col items-start text-sm"> <div className="flex flex-col items-start gap-1">
<li>- Train your abilities for the IELTS exam</li> <span>This includes:</span>
<li> <ul className="flex flex-col items-start text-sm">
- Gain insights into your weaknesses and strengths <li>
</li> - Allow a total of {user.corporateInformation.companyInformation.userAmount} students and teachers to
<li> use EnCoach
- Allow yourself to correctly prepare for the exam </li>
</li> <li>- Train their abilities for the IELTS exam</li>
</ul> <li>- Gain insights into your students&apos; weaknesses and strengths</li>
</div> <li>- Allow them to correctly prepare for the exam</li>
</div> </ul>
))} </div>
</PayPalScriptProvider> </div>
</div> </div>
</div> )}
)} {!isIndividual() && user.type !== "corporate" && (
{!isIndividual() && <div className="flex flex-col items-center">
user.type === "corporate" && <span className="max-w-lg">
user?.corporateInformation.payment && ( You are not the person in charge of your time credits, please contact your administrator about this situation.
<div className="flex flex-col items-center"> </span>
<span className="max-w-lg"> <span className="max-w-lg">
To add to your use of EnCoach and that of your students and If you believe this to be a mistake, please contact the platform&apos;s administration, thank you for your
teachers, please pay your designated package below: patience.
</span> </span>
<div </div>
className={clsx( )}
"flex flex-col items-start gap-6 rounded-xl bg-white p-4", {!isIndividual() && user.type === "corporate" && !user.corporateInformation.payment && (
)} <div className="flex flex-col items-center">
> <span className="max-w-lg">
<div className="mb-2 flex flex-col items-start"> An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users you
<img desire and your expected monthly duration.
src="/logo_title.png" </span>
alt="EnCoach's Logo" <span className="max-w-lg">
className="w-32" Please try again later or contact your agent or an admin, thank you for your patience.
/> </span>
<span className="text-xl font-semibold"> </div>
EnCoach - {user.corporateInformation?.monthlyDuration}{" "} )}
Months </div>
</span> </Layout>
</div> ) : (
<div className="flex w-full flex-col items-start gap-2"> <div />
<span className="text-2xl"> )}
{user.corporateInformation.payment.value} </>
{getSymbolFromCurrency( );
user.corporateInformation.payment.currency,
)}
</span>
<PayPalPayment
key={clientID}
clientID={clientID}
setIsLoading={setIsLoading}
currency={user.corporateInformation.payment.currency}
price={user.corporateInformation.payment.value}
duration={user.corporateInformation.monthlyDuration}
duration_unit="months"
onSuccess={() => {
setIsLoading(false);
setTimeout(reload, 500);
}}
loadScript
trackingId={trackingId}
/>
</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{" "}
{
user.corporateInformation.companyInformation
.userAmount
}{" "}
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() && user.type !== "corporate" && (
<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() &&
user.type === "corporate" &&
!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

@@ -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}`;
};