Built with Next.js, Horizon is a financial SaaS platform that connects to multiple bank accounts, displays transactions in real-time, allows users to transfer money to other platform users, and manages their finances altogether.
- Next.js
- TypeScript
- Appwrite
- Plaid
- Dwolla
- React Hook Form
- Zod
- TailwindCSS
- Chart.js
- ShadCN
👉 Authentication: An ultra-secure SSR authentication with proper validations and authorization
👉 Connect Banks: Integrates with Plaid for multiple bank account linking
👉 Home Page: Shows general overview of user account with total balance from all connected banks, recent transactions, money spent on different categories, etc
👉 My Banks: Check the complete list of all connected banks with respective balances, account details
👉 Transaction History: Includes pagination and filtering options for viewing transaction history of different banks
👉 Real-time Updates: Reflects changes across all relevant pages upon connecting new bank accounts.
👉 Funds Transfer: Allows users to transfer funds using Dwolla to other accounts with required fields and recipient bank ID.
👉 Responsiveness: Ensures the application adapts seamlessly to various screen sizes and devices, providing a consistent user experience across desktop, tablet, and mobile platforms.
and many more, including code architecture and reusability.
.env.example
#NEXT
NEXT_PUBLIC_SITE_URL=
#APPWRITE
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT=
APPWRITE_DATABASE_ID=
APPWRITE_USER_COLLECTION_ID=
APPWRITE_BANK_COLLECTION_ID=
APPWRITE_TRANSACTION_COLLECTION_ID=
APPWRITE_SECRET=
#PLAID
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=sandbox
PLAID_PRODUCTS=auth,transactions,identity
PLAID_COUNTRY_CODES=US,CA
#DWOLLA
DWOLLA_KEY=
DWOLLA_SECRET=
DWOLLA_BASE_URL=https://api-sandbox.dwolla.com
DWOLLA_ENV=sandboxexchangePublicToken
// This function exchanges a public token for an access token and item ID
export const exchangePublicToken = async ({
publicToken,
user,
}: exchangePublicTokenProps) => {
try {
// Exchange public token for access token and item ID
const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});
const accessToken = response.data.access_token;
const itemId = response.data.item_id;
// Get account information from Plaid using the access token
const accountsResponse = await plaidClient.accountsGet({
access_token: accessToken,
});
const accountData = accountsResponse.data.accounts[0];
// Create a processor token for Dwolla using the access token and account ID
const request: ProcessorTokenCreateRequest = {
access_token: accessToken,
account_id: accountData.account_id,
processor: "dwolla" as ProcessorTokenCreateRequestProcessorEnum,
};
const processorTokenResponse =
await plaidClient.processorTokenCreate(request);
const processorToken = processorTokenResponse.data.processor_token;
// Create a funding source URL for the account using the Dwolla customer ID, processor token, and bank name
const fundingSourceUrl = await addFundingSource({
dwollaCustomerId: user.dwollaCustomerId,
processorToken,
bankName: accountData.name,
});
// If the funding source URL is not created, throw an error
if (!fundingSourceUrl) throw Error;
// Create a bank account using the user ID, item ID, account ID, access token, funding source URL, and sharable ID
await createBankAccount({
userId: user.$id,
bankId: itemId,
accountId: accountData.account_id,
accessToken,
fundingSourceUrl,
sharableId: encryptId(accountData.account_id),
});
// Revalidate the path to reflect the changes
revalidatePath("/");
// Return a success message
return parseStringify({
publicTokenExchange: "complete",
});
} catch (error) {
// Log any errors that occur during the process
console.error("An error occurred while creating exchanging token:", error);
}
};user.actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { ID, Query } from "node-appwrite";
import {
CountryCode,
ProcessorTokenCreateRequest,
ProcessorTokenCreateRequestProcessorEnum,
Products,
} from "plaid";
import { plaidClient } from "@/lib/plaid.config";
import {
parseStringify,
extractCustomerIdFromUrl,
encryptId,
} from "@/lib/utils";
import { createAdminClient, createSessionClient } from "../appwrite.config";
import { addFundingSource, createDwollaCustomer } from "./dwolla.actions";
const {
APPWRITE_DATABASE_ID: DATABASE_ID,
APPWRITE_USER_COLLECTION_ID: USER_COLLECTION_ID,
APPWRITE_BANK_COLLECTION_ID: BANK_COLLECTION_ID,
} = process.env;
export const signUp = async ({ password, ...userData }: SignUpParams) => {
let newUserAccount;
try {
// create appwrite user
const { database, account } = await createAdminClient();
newUserAccount = await account.create(
ID.unique(),
userData.email,
password,
`${userData.firstName} ${userData.lastName}`
);
if (!newUserAccount) throw new Error("Error creating user");
// create dwolla customer
const dwollaCustomerUrl = await createDwollaCustomer({
...userData,
type: "personal",
});
if (!dwollaCustomerUrl) throw new Error("Error creating dwolla customer");
const dwollaCustomerId = extractCustomerIdFromUrl(dwollaCustomerUrl);
const newUser = await database.createDocument(
DATABASE_ID!,
USER_COLLECTION_ID!,
ID.unique(),
{
...userData,
userId: newUserAccount.$id,
dwollaCustomerUrl,
dwollaCustomerId,
}
);
const session = await account.createEmailPasswordSession(
userData.email,
password
);
cookies().set("appwrite-session", session.secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: true,
});
return parseStringify(newUser);
} catch (error) {
console.error("Error", error);
// check if account has been created, if so, delete it
if (newUserAccount?.$id) {
const { user } = await createAdminClient();
await user.delete(newUserAccount?.$id);
}
return null;
}
};
export const signIn = async ({ email, password }: signInProps) => {
try {
const { account } = await createAdminClient();
const session = await account.createEmailPasswordSession(email, password);
cookies().set("appwrite-session", session.secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: true,
});
const user = await getUserInfo({ userId: session.userId });
return parseStringify(user);
} catch (error) {
console.error("Error", error);
return null;
}
};
export const getLoggedInUser = async () => {
try {
const { account } = await createSessionClient();
const result = await account.get();
const user = await getUserInfo({ userId: result.$id });
return parseStringify(user);
} catch (error) {
console.error("Error", error);
return null;
}
};
// CREATE PLAID LINK TOKEN
export const createLinkToken = async (user: User) => {
try {
const tokeParams = {
user: {
client_user_id: user.$id,
},
client_name: user.firstName + user.lastName,
products: ["auth"] as Products[],
language: "en",
country_codes: ["US"] as CountryCode[],
};
const response = await plaidClient.linkTokenCreate(tokeParams);
return parseStringify({ linkToken: response.data.link_token });
} catch (error) {
console.error(
"An error occurred while creating a new Horizon user:",
error
);
}
};
// EXCHANGE PLAID PUBLIC TOKEN
// This function exchanges a public token for an access token and item ID
export const exchangePublicToken = async ({
publicToken,
user,
}: exchangePublicTokenProps) => {
try {
// Exchange public token for access token and item ID
const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});
const accessToken = response.data.access_token;
const itemId = response.data.item_id;
// Get account information from Plaid using the access token
const accountsResponse = await plaidClient.accountsGet({
access_token: accessToken,
});
const accountData = accountsResponse.data.accounts[0];
// Create a processor token for Dwolla using the access token and account ID
const request: ProcessorTokenCreateRequest = {
access_token: accessToken,
account_id: accountData.account_id,
processor: "dwolla" as ProcessorTokenCreateRequestProcessorEnum,
};
const processorTokenResponse =
await plaidClient.processorTokenCreate(request);
const processorToken = processorTokenResponse.data.processor_token;
// Create a funding source URL for the account using the Dwolla customer ID, processor token, and bank name
const fundingSourceUrl = await addFundingSource({
dwollaCustomerId: user.dwollaCustomerId,
processorToken,
bankName: accountData.name,
});
// If the funding source URL is not created, throw an error
if (!fundingSourceUrl) throw Error;
// Create a bank account using the user ID, item ID, account ID, access token, funding source URL, and sharable ID
await createBankAccount({
userId: user.$id,
bankId: itemId,
accountId: accountData.account_id,
accessToken,
fundingSourceUrl,
sharableId: encryptId(accountData.account_id),
});
// Revalidate the path to reflect the changes
revalidatePath("/");
// Return a success message
return parseStringify({
publicTokenExchange: "complete",
});
} catch (error) {
// Log any errors that occur during the process
console.error("An error occurred while creating exchanging token:", error);
}
};
export const getUserInfo = async ({ userId }: getUserInfoProps) => {
try {
const { database } = await createAdminClient();
const user = await database.listDocuments(
DATABASE_ID!,
USER_COLLECTION_ID!,
[Query.equal("userId", [userId])]
);
if (user.total !== 1) return null;
return parseStringify(user.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};
export const createBankAccount = async ({
accessToken,
userId,
accountId,
bankId,
fundingSourceUrl,
sharableId,
}: createBankAccountProps) => {
try {
const { database } = await createAdminClient();
const bankAccount = await database.createDocument(
DATABASE_ID!,
BANK_COLLECTION_ID!,
ID.unique(),
{
accessToken,
userId,
accountId,
bankId,
fundingSourceUrl,
sharableId,
}
);
return parseStringify(bankAccount);
} catch (error) {
console.error("Error", error);
return null;
}
};
// get user bank accounts
export const getBanks = async ({ userId }: getBanksProps) => {
try {
const { database } = await createAdminClient();
const banks = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("userId", [userId])]
);
return parseStringify(banks.documents);
} catch (error) {
console.error("Error", error);
return null;
}
};
// get specific bank from bank collection by document id
export const getBank = async ({ documentId }: getBankProps) => {
try {
const { database } = await createAdminClient();
const bank = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("$id", [documentId])]
);
if (bank.total !== 1) return null;
return parseStringify(bank.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};
// get specific bank from bank collection by account id
export const getBankByAccountId = async ({
accountId,
}: getBankByAccountIdProps) => {
try {
const { database } = await createAdminClient();
const bank = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("accountId", [accountId])]
);
if (bank.total !== 1) return null;
return parseStringify(bank.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};dwolla.actions.ts
"use server";
import { Client } from "dwolla-v2";
const getEnvironment = (): "production" | "sandbox" => {
const environment = process.env.DWOLLA_ENV as string;
switch (environment) {
case "sandbox":
return "sandbox";
case "production":
return "production";
default:
throw new Error(
"Dwolla environment should either be set to `sandbox` or `production`"
);
}
};
const dwollaClient = new Client({
environment: getEnvironment(),
key: process.env.DWOLLA_KEY as string,
secret: process.env.DWOLLA_SECRET as string,
});
// Create a Dwolla Funding Source using a Plaid Processor Token
export const createFundingSource = async (
options: CreateFundingSourceOptions
) => {
try {
return await dwollaClient
.post(`customers/${options.customerId}/funding-sources`, {
name: options.fundingSourceName,
plaidToken: options.plaidToken,
})
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Creating a Funding Source Failed: ", err);
}
};
export const createOnDemandAuthorization = async () => {
try {
const onDemandAuthorization = await dwollaClient.post(
"on-demand-authorizations"
);
const authLink = onDemandAuthorization.body._links;
return authLink;
} catch (err) {
console.error("Creating an On Demand Authorization Failed: ", err);
}
};
export const createDwollaCustomer = async (
newCustomer: NewDwollaCustomerParams
) => {
try {
return await dwollaClient
.post("customers", newCustomer)
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Creating a Dwolla Customer Failed: ", err);
}
};
export const createTransfer = async ({
sourceFundingSourceUrl,
destinationFundingSourceUrl,
amount,
}: TransferParams) => {
try {
const requestBody = {
_links: {
source: {
href: sourceFundingSourceUrl,
},
destination: {
href: destinationFundingSourceUrl,
},
},
amount: {
currency: "USD",
value: amount,
},
};
return await dwollaClient
.post("transfers", requestBody)
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Transfer fund failed: ", err);
}
};
export const addFundingSource = async ({
dwollaCustomerId,
processorToken,
bankName,
}: AddFundingSourceParams) => {
try {
// create dwolla auth link
const dwollaAuthLinks = await createOnDemandAuthorization();
// add funding source to the dwolla customer & get the funding source url
const fundingSourceOptions = {
customerId: dwollaCustomerId,
fundingSourceName: bankName,
plaidToken: processorToken,
_links: dwollaAuthLinks,
};
return await createFundingSource(fundingSourceOptions);
} catch (err) {
console.error("Transfer fund failed: ", err);
}
};bank.actions.ts
"use server";
import {
ACHClass,
CountryCode,
TransferAuthorizationCreateRequest,
TransferCreateRequest,
TransferNetwork,
TransferType,
} from "plaid";
import { plaidClient } from "../plaid.config";
import { parseStringify } from "../utils";
import { getTransactionsByBankId } from "./transaction.actions";
import { getBanks, getBank } from "./user.actions";
// Get multiple bank accounts
export const getAccounts = async ({ userId }: getAccountsProps) => {
try {
// get banks from db
const banks = await getBanks({ userId });
const accounts = await Promise.all(
banks?.map(async (bank: Bank) => {
// get each account info from plaid
const accountsResponse = await plaidClient.accountsGet({
access_token: bank.accessToken,
});
const accountData = accountsResponse.data.accounts[0];
// get institution info from plaid
const institution = await getInstitution({
institutionId: accountsResponse.data.item.institution_id!,
});
const account = {
id: accountData.account_id,
availableBalance: accountData.balances.available!,
currentBalance: accountData.balances.current!,
institutionId: institution.institution_id,
name: accountData.name,
officialName: accountData.official_name,
mask: accountData.mask!,
type: accountData.type as string,
subtype: accountData.subtype! as string,
appwriteItemId: bank.$id,
sharableId: bank.sharableId,
};
return account;
})
);
const totalBanks = accounts.length;
const totalCurrentBalance = accounts.reduce((total, account) => {
return total + account.currentBalance;
}, 0);
return parseStringify({ data: accounts, totalBanks, totalCurrentBalance });
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};
// Get one bank account
export const getAccount = async ({ appwriteItemId }: getAccountProps) => {
try {
// get bank from db
const bank = await getBank({ documentId: appwriteItemId });
// get account info from plaid
const accountsResponse = await plaidClient.accountsGet({
access_token: bank.accessToken,
});
const accountData = accountsResponse.data.accounts[0];
// get transfer transactions from appwrite
const transferTransactionsData = await getTransactionsByBankId({
bankId: bank.$id,
});
const transferTransactions = transferTransactionsData.documents.map(
(transferData: Transaction) => ({
id: transferData.$id,
name: transferData.name!,
amount: transferData.amount!,
date: transferData.$createdAt,
paymentChannel: transferData.channel,
category: transferData.category,
type: transferData.senderBankId === bank.$id ? "debit" : "credit",
})
);
// get institution info from plaid
const institution = await getInstitution({
institutionId: accountsResponse.data.item.institution_id!,
});
const transactions = await getTransactions({
accessToken: bank?.accessToken,
});
const account = {
id: accountData.account_id,
availableBalance: accountData.balances.available!,
currentBalance: accountData.balances.current!,
institutionId: institution.institution_id,
name: accountData.name,
officialName: accountData.official_name,
mask: accountData.mask!,
type: accountData.type as string,
subtype: accountData.subtype! as string,
appwriteItemId: bank.$id,
};
// sort transactions by date such that the most recent transaction is first
const allTransactions = [...transactions, ...transferTransactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
return parseStringify({
data: account,
transactions: allTransactions,
});
} catch (error) {
console.error("An error occurred while getting the account:", error);
}
};
// Get bank info
export const getInstitution = async ({
institutionId,
}: getInstitutionProps) => {
try {
const institutionResponse = await plaidClient.institutionsGetById({
institution_id: institutionId,
country_codes: ["US"] as CountryCode[],
});
const intitution = institutionResponse.data.institution;
return parseStringify(intitution);
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};
// Get transactions
export const getTransactions = async ({
accessToken,
}: getTransactionsProps) => {
let hasMore = true;
let transactions: any = [];
try {
// Iterate through each page of new transaction updates for item
while (hasMore) {
const response = await plaidClient.transactionsSync({
access_token: accessToken,
});
const data = response.data;
transactions = response.data.added.map((transaction) => ({
id: transaction.transaction_id,
name: transaction.name,
paymentChannel: transaction.payment_channel,
type: transaction.payment_channel,
accountId: transaction.account_id,
amount: transaction.amount,
pending: transaction.pending,
category: transaction.category ? transaction.category[0] : "",
date: transaction.date,
image: transaction.logo_url,
}));
hasMore = data.has_more;
}
return parseStringify(transactions);
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};
// Create Transfer
export const createTransfer = async () => {
const transferAuthRequest: TransferAuthorizationCreateRequest = {
access_token: "access-sandbox-cddd20c1-5ba8-4193-89f9-3a0b91034c25",
account_id: "Zl8GWV1jqdTgjoKnxQn1HBxxVBanm5FxZpnQk",
funding_account_id: "442d857f-fe69-4de2-a550-0c19dc4af467",
type: "credit" as TransferType,
network: "ach" as TransferNetwork,
amount: "10.00",
ach_class: "ppd" as ACHClass,
user: {
legal_name: "Anne Charleston",
},
};
try {
const transferAuthResponse =
await plaidClient.transferAuthorizationCreate(transferAuthRequest);
const authorizationId = transferAuthResponse.data.authorization.id;
const transferCreateRequest: TransferCreateRequest = {
access_token: "access-sandbox-cddd20c1-5ba8-4193-89f9-3a0b91034c25",
account_id: "Zl8GWV1jqdTgjoKnxQn1HBxxVBanm5FxZpnQk",
description: "payment",
authorization_id: authorizationId,
};
const responseCreateResponse = await plaidClient.transferCreate(
transferCreateRequest
);
const transfer = responseCreateResponse.data.transfer;
return parseStringify(transfer);
} catch (error) {
console.error(
"An error occurred while creating transfer authorization:",
error
);
}
};BankTabItem.tsx
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { cn, formUrlQuery } from "@/lib/utils";
export const BankTabItem = ({ account, appwriteItemId }: BankTabItemProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const isActive = appwriteItemId === account?.appwriteItemId;
const handleBankChange = () => {
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: account?.appwriteItemId,
});
router.push(newUrl, { scroll: false });
};
return (
<div
onClick={handleBankChange}
className={cn(`banktab-item`, {
" border-blue-600": isActive,
})}
>
<p
className={cn(`text-16 line-clamp-1 flex-1 font-medium text-gray-500`, {
" text-blue-600": isActive,
})}
>
{account.name}
</p>
</div>
);
};BankInfo.tsx
"use client";
import Image from "next/image";
import { useSearchParams, useRouter } from "next/navigation";
import {
cn,
formUrlQuery,
formatAmount,
getAccountTypeColors,
} from "@/lib/utils";
const BankInfo = ({ account, appwriteItemId, type }: BankInfoProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const isActive = appwriteItemId === account?.appwriteItemId;
const handleBankChange = () => {
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: account?.appwriteItemId,
});
router.push(newUrl, { scroll: false });
};
const colors = getAccountTypeColors(account?.type as AccountTypes);
return (
<div
onClick={handleBankChange}
className={cn(`bank-info ${colors.bg}`, {
"shadow-sm border-blue-700": type === "card" && isActive,
"rounded-xl": type === "card",
"hover:shadow-sm cursor-pointer": type === "card",
})}
>
<figure
className={`flex-center h-fit rounded-full bg-blue-100 ${colors.lightBg}`}
>
<Image
src="/icons/connect-bank.svg"
width={20}
height={20}
alt={account.subtype}
className="m-2 min-w-5"
/>
</figure>
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="bank-info_content">
<h2
className={`text-16 line-clamp-1 flex-1 font-bold text-blue-900 ${colors.title}`}
>
{account.name}
</h2>
{type === "full" && (
<p
className={`text-12 rounded-full px-3 py-1 font-medium text-blue-700 ${colors.subText} ${colors.lightBg}`}
>
{account.subtype}
</p>
)}
</div>
<p className={`text-16 font-medium text-blue-700 ${colors.subText}`}>
{formatAmount(account.currentBalance)}
</p>
</div>
</div>
);
};
export default BankInfo;Copy.tsx
"use client";
import { useState } from "react";
import { Button } from "./ui/button";
const Copy = ({ title }: { title: string }) => {
const [hasCopied, setHasCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(title);
setHasCopied(true);
setTimeout(() => {
setHasCopied(false);
}, 2000);
};
return (
<Button
data-state="closed"
className="mt-3 flex max-w-[320px] gap-4"
variant="secondary"
onClick={copyToClipboard}
>
<p className="line-clamp-1 w-full max-w-full text-xs font-medium text-black-2">
{title}
</p>
{!hasCopied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="mr-2 size-4"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="mr-2 size-4"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</Button>
);
};
export default Copy;PaymentTransferForm.tsx
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createTransfer } from "@/lib/actions/dwolla.actions";
import { createTransaction } from "@/lib/actions/transaction.actions";
import { getBank, getBankByAccountId } from "@/lib/actions/user.actions";
import { decryptId } from "@/lib/utils";
import { BankDropdown } from "./bank/BankDropdown";
import { Button } from "./ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "./ui/form";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
const formSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(4, "Transfer note is too short"),
amount: z.string().min(4, "Amount is too short"),
senderBank: z.string().min(4, "Please select a valid bank account"),
sharableId: z.string().min(8, "Please select a valid sharable Id"),
});
const PaymentTransferForm = ({ accounts }: PaymentTransferFormProps) => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
amount: "",
senderBank: "",
sharableId: "",
},
});
const submit = async (data: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
const receiverAccountId = decryptId(data.sharableId);
const receiverBank = await getBankByAccountId({
accountId: receiverAccountId,
});
const senderBank = await getBank({ documentId: data.senderBank });
const transferParams = {
sourceFundingSourceUrl: senderBank.fundingSourceUrl,
destinationFundingSourceUrl: receiverBank.fundingSourceUrl,
amount: data.amount,
};
// create transfer
const transfer = await createTransfer(transferParams);
// create transfer transaction
if (transfer) {
const transaction = {
name: data.name,
amount: data.amount,
senderId: senderBank.userId.$id,
senderBankId: senderBank.$id,
receiverId: receiverBank.userId.$id,
receiverBankId: receiverBank.$id,
email: data.email,
};
const newTransaction = await createTransaction(transaction);
if (newTransaction) {
form.reset();
router.push("/");
}
}
} catch (error) {
console.error("Submitting create transfer request failed: ", error);
}
setIsLoading(false);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)} className="flex flex-col">
<FormField
control={form.control}
name="senderBank"
render={() => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-6 pt-5">
<div className="payment-transfer_form-content">
<FormLabel className="text-14 font-medium text-gray-700">
Select Source Bank
</FormLabel>
<FormDescription className="text-12 font-normal text-gray-600">
Select the bank account you want to transfer funds from
</FormDescription>
</div>
<div className="flex w-full flex-col">
<FormControl>
<BankDropdown
accounts={accounts}
setValue={form.setValue}
otherStyles="!w-full"
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-6 pt-5">
<div className="payment-transfer_form-content">
<FormLabel className="text-14 font-medium text-gray-700">
Transfer Note (Optional)
</FormLabel>
<FormDescription className="text-12 font-normal text-gray-600">
Please provide any additional information or instructions
related to the transfer
</FormDescription>
</div>
<div className="flex w-full flex-col">
<FormControl>
<Textarea
placeholder="Write a short note here"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<div className="payment-transfer_form-details">
<h2 className="text-18 font-semibold text-gray-900">
Bank account details
</h2>
<p className="text-16 font-normal text-gray-600">
Enter the bank account details of the recipient
</p>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item py-5">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Recipient's Email Address
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="ex: johndoe@gmail.com"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sharableId"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-5 pt-6">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Receiver's Plaid Sharable Id
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="Enter the public account number"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="border-y border-gray-200">
<div className="payment-transfer_form-item py-5">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Amount
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="ex: 5.00"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<div className="payment-transfer_btn-box">
<Button type="submit" className="payment-transfer_btn">
{isLoading ? (
<>
<Loader2 size={20} className="animate-spin" /> Sending...
</>
) : (
"Transfer Funds"
)}
</Button>
</div>
</form>
</Form>
);
};
export default PaymentTransferForm;Missing from the video (top right on the transaction list page) BankDropdown.tsx
"use client";
import Image from "next/image";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import { formUrlQuery, formatAmount } from "@/lib/utils";
export const BankDropdown = ({
accounts = [],
setValue,
otherStyles,
}: BankDropdownProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const [selected, setSeclected] = useState(accounts[0]);
const handleBankChange = (id: string) => {
const account = accounts.find((account) => account.appwriteItemId === id)!;
setSeclected(account);
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: id,
});
router.push(newUrl, { scroll: false });
if (setValue) {
setValue("senderBank", id);
}
};
return (
<Select
defaultValue={selected.id}
onValueChange={(value) => handleBankChange(value)}
>
<SelectTrigger
className={`flex w-full gap-3 md:w-[300px] ${otherStyles}`}
>
<Image
src="icons/credit-card.svg"
width={20}
height={20}
alt="account"
/>
<p className="line-clamp-1 w-full text-left">{selected.name}</p>
</SelectTrigger>
<SelectContent
className={`w-full md:w-[300px] ${otherStyles}`}
align="end"
>
<SelectGroup>
<SelectLabel className="py-2 font-normal text-gray-500">
Select a bank to display
</SelectLabel>
{accounts.map((account: Account) => (
<SelectItem
key={account.id}
value={account.appwriteItemId}
className="cursor-pointer border-t"
>
<div className="flex flex-col ">
<p className="text-16 font-medium">{account.name}</p>
<p className="text-14 font-medium text-blue-600">
{formatAmount(account.currentBalance)}
</p>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
};Pagination.tsx
"use client";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { formUrlQuery } from "@/lib/utils";
export const Pagination = ({ page, totalPages }: PaginationProps) => {
const router = useRouter();
const searchParams = useSearchParams()!;
const handleNavigation = (type: "prev" | "next") => {
const pageNumber = type === "prev" ? page - 1 : page + 1;
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "page",
value: pageNumber.toString(),
});
router.push(newUrl, { scroll: false });
};
return (
<div className="flex justify-between gap-3">
<Button
size="lg"
variant="ghost"
className="p-0 hover:bg-transparent"
onClick={() => handleNavigation("prev")}
disabled={Number(page) <= 1}
>
<Image
src="/icons/arrow-left.svg"
alt="arrow"
width={20}
height={20}
className="mr-2"
/>
Prev
</Button>
<p className="text-14 flex items-center px-2">
{page} / {totalPages}
</p>
<Button
size="lg"
variant="ghost"
className="p-0 hover:bg-transparent"
onClick={() => handleNavigation("next")}
disabled={Number(page) >= totalPages}
>
Next
<Image
src="/icons/arrow-left.svg"
alt="arrow"
width={20}
height={20}
className="ml-2 -scale-x-100"
/>
</Button>
</div>
);
};Category.tsx
import Image from "next/image";
import { topCategoryStyles } from "@/constants";
import { cn } from "@/lib/utils";
import { Progress } from "./ui/progress";
export const Category = ({ category }: CategoryProps) => {
const {
bg,
circleBg,
text: { main, count },
progress: { bg: progressBg, indicator },
icon,
} = topCategoryStyles[category.name as keyof typeof topCategoryStyles] ||
topCategoryStyles.default;
return (
<div className={cn("gap-[18px] flex p-4 rounded-xl", bg)}>
<figure className={cn("flex-center size-10 rounded-full", circleBg)}>
<Image src={icon} width={20} height={20} alt={category.name} />
</figure>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="text-14 flex justify-between">
<h2 className={cn("font-medium", main)}>{category.name}</h2>
<h3 className={cn("font-normal", count)}>{category.count}</h3>
</div>
<Progress
value={(category.count / category.totalCount) * 100}
className={cn("h-2 w-full", progressBg)}
indicatorClassName={cn("h-2 w-full", indicator)}
/>
</div>
</div>
);
};