Repository: piyush-eon/ai-finance-platform
Branch: main
Commit: cc17f99af70e
Files: 77
Total size: 176.3 KB
Directory structure:
gitextract_amwpott_/
├── .eslintrc.json
├── .gitignore
├── README.md
├── actions/
│ ├── account.js
│ ├── budget.js
│ ├── dashboard.js
│ ├── seed.js
│ ├── send-email.js
│ └── transaction.js
├── app/
│ ├── (auth)/
│ │ ├── layout.js
│ │ ├── sign-in/
│ │ │ └── [[...sign-in]]/
│ │ │ └── page.jsx
│ │ └── sign-up/
│ │ └── [[...sign-up]]/
│ │ └── page.jsx
│ ├── (main)/
│ │ ├── account/
│ │ │ ├── [id]/
│ │ │ │ └── page.jsx
│ │ │ └── _components/
│ │ │ ├── account-chart.jsx
│ │ │ ├── no-pagination-transaction-table.jsx
│ │ │ └── transaction-table.jsx
│ │ ├── dashboard/
│ │ │ ├── _components/
│ │ │ │ ├── account-card.jsx
│ │ │ │ ├── budget-progress.jsx
│ │ │ │ └── transaction-overview.jsx
│ │ │ ├── layout.js
│ │ │ └── page.jsx
│ │ ├── layout.js
│ │ └── transaction/
│ │ ├── _components/
│ │ │ ├── recipt-scanner.jsx
│ │ │ └── transaction-form.jsx
│ │ └── create/
│ │ └── page.jsx
│ ├── api/
│ │ ├── inngest/
│ │ │ └── route.js
│ │ └── seed/
│ │ └── route.js
│ ├── globals.css
│ ├── layout.js
│ ├── lib/
│ │ └── schema.js
│ ├── not-found.jsx
│ └── page.js
├── components/
│ ├── create-account-drawer.jsx
│ ├── header.jsx
│ ├── hero.jsx
│ └── ui/
│ ├── badge.jsx
│ ├── button.jsx
│ ├── calendar.jsx
│ ├── card.jsx
│ ├── checkbox.jsx
│ ├── drawer.jsx
│ ├── dropdown-menu.jsx
│ ├── input.jsx
│ ├── popover.jsx
│ ├── progress.jsx
│ ├── select.jsx
│ ├── sonner.jsx
│ ├── switch.jsx
│ ├── table.jsx
│ └── tooltip.jsx
├── components.json
├── data/
│ ├── categories.js
│ └── landing.js
├── emails/
│ └── template.jsx
├── hooks/
│ └── use-fetch.js
├── jsconfig.json
├── lib/
│ ├── arcjet.js
│ ├── checkUser.js
│ ├── inngest/
│ │ ├── client.js
│ │ └── function.js
│ ├── prisma.js
│ └── utils.js
├── middleware.js
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── prisma/
│ ├── migrations/
│ │ ├── 20241204141034_init/
│ │ │ └── migration.sql
│ │ ├── 20241205074927_remove_currency/
│ │ │ └── migration.sql
│ │ ├── 20241205094020_remove_categories/
│ │ │ └── migration.sql
│ │ ├── 20241205094352_remove_categories/
│ │ │ └── migration.sql
│ │ ├── 20241206121749_budget/
│ │ │ └── migration.sql
│ │ ├── 20241208092553_budget/
│ │ │ └── migration.sql
│ │ ├── 20241208122341_budget/
│ │ │ └── migration.sql
│ │ ├── 20241209133842_remove/
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
└── tailwind.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": "next/core-web-vitals",
"rules": {
"no-unused-vars": ["warn"]
}
}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
================================================
FILE: README.md
================================================
# Full Stack AI Fianace Platform with Next JS, Supabase, Tailwind, Prisma, Inngest, ArcJet, Shadcn UI Tutorial 🔥🔥
## https://youtu.be/egS6fnZAdzk
<img width="1470" alt="Screenshot 2024-12-10 at 9 45 45 AM" src="https://github.com/user-attachments/assets/1bc50b85-b421-4122-8ba4-ae68b2b61432">
### Make sure to create a `.env` file with following variables -
```
DATABASE_URL=
DIRECT_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/onboarding
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
GEMINI_API_KEY=
RESEND_API_KEY=
ARCJET_KEY=
```
================================================
FILE: actions/account.js
================================================
"use server";
import { db } from "@/lib/prisma";
import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
const serializeDecimal = (obj) => {
const serialized = { ...obj };
if (obj.balance) {
serialized.balance = obj.balance.toNumber();
}
if (obj.amount) {
serialized.amount = obj.amount.toNumber();
}
return serialized;
};
export async function getAccountWithTransactions(accountId) {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) throw new Error("User not found");
const account = await db.account.findUnique({
where: {
id: accountId,
userId: user.id,
},
include: {
transactions: {
orderBy: { date: "desc" },
},
_count: {
select: { transactions: true },
},
},
});
if (!account) return null;
return {
...serializeDecimal(account),
transactions: account.transactions.map(serializeDecimal),
};
}
export async function bulkDeleteTransactions(transactionIds) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) throw new Error("User not found");
// Get transactions to calculate balance changes
const transactions = await db.transaction.findMany({
where: {
id: { in: transactionIds },
userId: user.id,
},
});
// Group transactions by account to update balances
const accountBalanceChanges = transactions.reduce((acc, transaction) => {
const change =
transaction.type === "EXPENSE"
? transaction.amount
: -transaction.amount;
acc[transaction.accountId] = (acc[transaction.accountId] || 0) + change;
return acc;
}, {});
// Delete transactions and update account balances in a transaction
await db.$transaction(async (tx) => {
// Delete transactions
await tx.transaction.deleteMany({
where: {
id: { in: transactionIds },
userId: user.id,
},
});
// Update account balances
for (const [accountId, balanceChange] of Object.entries(
accountBalanceChanges
)) {
await tx.account.update({
where: { id: accountId },
data: {
balance: {
increment: balanceChange,
},
},
});
}
});
revalidatePath("/dashboard");
revalidatePath("/account/[id]");
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
export async function updateDefaultAccount(accountId) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
// First, unset any existing default account
await db.account.updateMany({
where: {
userId: user.id,
isDefault: true,
},
data: { isDefault: false },
});
// Then set the new default account
const account = await db.account.update({
where: {
id: accountId,
userId: user.id,
},
data: { isDefault: true },
});
revalidatePath("/dashboard");
return { success: true, data: serializeTransaction(account) };
} catch (error) {
return { success: false, error: error.message };
}
}
================================================
FILE: actions/budget.js
================================================
"use server";
import { db } from "@/lib/prisma";
import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
export async function getCurrentBudget(accountId) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
const budget = await db.budget.findFirst({
where: {
userId: user.id,
},
});
// Get current month's expenses
const currentDate = new Date();
const startOfMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
1
);
const endOfMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
0
);
const expenses = await db.transaction.aggregate({
where: {
userId: user.id,
type: "EXPENSE",
date: {
gte: startOfMonth,
lte: endOfMonth,
},
accountId,
},
_sum: {
amount: true,
},
});
return {
budget: budget ? { ...budget, amount: budget.amount.toNumber() } : null,
currentExpenses: expenses._sum.amount
? expenses._sum.amount.toNumber()
: 0,
};
} catch (error) {
console.error("Error fetching budget:", error);
throw error;
}
}
export async function updateBudget(amount) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) throw new Error("User not found");
// Update or create budget
const budget = await db.budget.upsert({
where: {
userId: user.id,
},
update: {
amount,
},
create: {
userId: user.id,
amount,
},
});
revalidatePath("/dashboard");
return {
success: true,
data: { ...budget, amount: budget.amount.toNumber() },
};
} catch (error) {
console.error("Error updating budget:", error);
return { success: false, error: error.message };
}
}
================================================
FILE: actions/dashboard.js
================================================
"use server";
import aj from "@/lib/arcjet";
import { db } from "@/lib/prisma";
import { request } from "@arcjet/next";
import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
const serializeTransaction = (obj) => {
const serialized = { ...obj };
if (obj.balance) {
serialized.balance = obj.balance.toNumber();
}
if (obj.amount) {
serialized.amount = obj.amount.toNumber();
}
return serialized;
};
export async function getUserAccounts() {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
try {
const accounts = await db.account.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
include: {
_count: {
select: {
transactions: true,
},
},
},
});
// Serialize accounts before sending to client
const serializedAccounts = accounts.map(serializeTransaction);
return serializedAccounts;
} catch (error) {
console.error(error.message);
}
}
export async function createAccount(data) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
// Get request data for ArcJet
const req = await request();
// Check rate limit
const decision = await aj.protect(req, {
userId,
requested: 1, // Specify how many tokens to consume
});
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
const { remaining, reset } = decision.reason;
console.error({
code: "RATE_LIMIT_EXCEEDED",
details: {
remaining,
resetInSeconds: reset,
},
});
throw new Error("Too many requests. Please try again later.");
}
throw new Error("Request blocked");
}
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
// Convert balance to float before saving
const balanceFloat = parseFloat(data.balance);
if (isNaN(balanceFloat)) {
throw new Error("Invalid balance amount");
}
// Check if this is the user's first account
const existingAccounts = await db.account.findMany({
where: { userId: user.id },
});
// If it's the first account, make it default regardless of user input
// If not, use the user's preference
const shouldBeDefault =
existingAccounts.length === 0 ? true : data.isDefault;
// If this account should be default, unset other default accounts
if (shouldBeDefault) {
await db.account.updateMany({
where: { userId: user.id, isDefault: true },
data: { isDefault: false },
});
}
// Create new account
const account = await db.account.create({
data: {
...data,
balance: balanceFloat,
userId: user.id,
isDefault: shouldBeDefault, // Override the isDefault based on our logic
},
});
// Serialize the account before returning
const serializedAccount = serializeTransaction(account);
revalidatePath("/dashboard");
return { success: true, data: serializedAccount };
} catch (error) {
throw new Error(error.message);
}
}
export async function getDashboardData() {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
// Get all user transactions
const transactions = await db.transaction.findMany({
where: { userId: user.id },
orderBy: { date: "desc" },
});
return transactions.map(serializeTransaction);
}
================================================
FILE: actions/seed.js
================================================
"use server";
import { db } from "@/lib/prisma";
import { subDays } from "date-fns";
const ACCOUNT_ID = "account-id";
const USER_ID = "user-id";
// Categories with their typical amount ranges
const CATEGORIES = {
INCOME: [
{ name: "salary", range: [5000, 8000] },
{ name: "freelance", range: [1000, 3000] },
{ name: "investments", range: [500, 2000] },
{ name: "other-income", range: [100, 1000] },
],
EXPENSE: [
{ name: "housing", range: [1000, 2000] },
{ name: "transportation", range: [100, 500] },
{ name: "groceries", range: [200, 600] },
{ name: "utilities", range: [100, 300] },
{ name: "entertainment", range: [50, 200] },
{ name: "food", range: [50, 150] },
{ name: "shopping", range: [100, 500] },
{ name: "healthcare", range: [100, 1000] },
{ name: "education", range: [200, 1000] },
{ name: "travel", range: [500, 2000] },
],
};
// Helper to generate random amount within a range
function getRandomAmount(min, max) {
return Number((Math.random() * (max - min) + min).toFixed(2));
}
// Helper to get random category with amount
function getRandomCategory(type) {
const categories = CATEGORIES[type];
const category = categories[Math.floor(Math.random() * categories.length)];
const amount = getRandomAmount(category.range[0], category.range[1]);
return { category: category.name, amount };
}
export async function seedTransactions() {
try {
// Generate 90 days of transactions
const transactions = [];
let totalBalance = 0;
for (let i = 90; i >= 0; i--) {
const date = subDays(new Date(), i);
// Generate 1-3 transactions per day
const transactionsPerDay = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < transactionsPerDay; j++) {
// 40% chance of income, 60% chance of expense
const type = Math.random() < 0.4 ? "INCOME" : "EXPENSE";
const { category, amount } = getRandomCategory(type);
const transaction = {
id: crypto.randomUUID(),
type,
amount,
description: `${
type === "INCOME" ? "Received" : "Paid for"
} ${category}`,
date,
category,
status: "COMPLETED",
userId: USER_ID,
accountId: ACCOUNT_ID,
createdAt: date,
updatedAt: date,
};
totalBalance += type === "INCOME" ? amount : -amount;
transactions.push(transaction);
}
}
// Insert transactions in batches and update account balance
await db.$transaction(async (tx) => {
// Clear existing transactions
await tx.transaction.deleteMany({
where: { accountId: ACCOUNT_ID },
});
// Insert new transactions
await tx.transaction.createMany({
data: transactions,
});
// Update account balance
await tx.account.update({
where: { id: ACCOUNT_ID },
data: { balance: totalBalance },
});
});
return {
success: true,
message: `Created ${transactions.length} transactions`,
};
} catch (error) {
console.error("Error seeding transactions:", error);
return { success: false, error: error.message };
}
}
================================================
FILE: actions/send-email.js
================================================
"use server";
import { Resend } from "resend";
export async function sendEmail({ to, subject, react }) {
const resend = new Resend(process.env.RESEND_API_KEY || "");
try {
const data = await resend.emails.send({
from: "Finance App <onboarding@resend.dev>",
to,
subject,
react,
});
return { success: true, data };
} catch (error) {
console.error("Failed to send email:", error);
return { success: false, error };
}
}
================================================
FILE: actions/transaction.js
================================================
"use server";
import { auth } from "@clerk/nextjs/server";
import { db } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { GoogleGenerativeAI } from "@google/generative-ai";
import aj from "@/lib/arcjet";
import { request } from "@arcjet/next";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const serializeAmount = (obj) => ({
...obj,
amount: obj.amount.toNumber(),
});
// Create Transaction
export async function createTransaction(data) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
// Get request data for ArcJet
const req = await request();
// Check rate limit
const decision = await aj.protect(req, {
userId,
requested: 1, // Specify how many tokens to consume
});
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
const { remaining, reset } = decision.reason;
console.error({
code: "RATE_LIMIT_EXCEEDED",
details: {
remaining,
resetInSeconds: reset,
},
});
throw new Error("Too many requests. Please try again later.");
}
throw new Error("Request blocked");
}
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
const account = await db.account.findUnique({
where: {
id: data.accountId,
userId: user.id,
},
});
if (!account) {
throw new Error("Account not found");
}
// Calculate new balance
const balanceChange = data.type === "EXPENSE" ? -data.amount : data.amount;
const newBalance = account.balance.toNumber() + balanceChange;
// Create transaction and update account balance
const transaction = await db.$transaction(async (tx) => {
const newTransaction = await tx.transaction.create({
data: {
...data,
userId: user.id,
nextRecurringDate:
data.isRecurring && data.recurringInterval
? calculateNextRecurringDate(data.date, data.recurringInterval)
: null,
},
});
await tx.account.update({
where: { id: data.accountId },
data: { balance: newBalance },
});
return newTransaction;
});
revalidatePath("/dashboard");
revalidatePath(`/account/${transaction.accountId}`);
return { success: true, data: serializeAmount(transaction) };
} catch (error) {
throw new Error(error.message);
}
}
export async function getTransaction(id) {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) throw new Error("User not found");
const transaction = await db.transaction.findUnique({
where: {
id,
userId: user.id,
},
});
if (!transaction) throw new Error("Transaction not found");
return serializeAmount(transaction);
}
export async function updateTransaction(id, data) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) throw new Error("User not found");
// Get original transaction to calculate balance change
const originalTransaction = await db.transaction.findUnique({
where: {
id,
userId: user.id,
},
include: {
account: true,
},
});
if (!originalTransaction) throw new Error("Transaction not found");
// Calculate balance changes
const oldBalanceChange =
originalTransaction.type === "EXPENSE"
? -originalTransaction.amount.toNumber()
: originalTransaction.amount.toNumber();
const newBalanceChange =
data.type === "EXPENSE" ? -data.amount : data.amount;
const netBalanceChange = newBalanceChange - oldBalanceChange;
// Update transaction and account balance in a transaction
const transaction = await db.$transaction(async (tx) => {
const updated = await tx.transaction.update({
where: {
id,
userId: user.id,
},
data: {
...data,
nextRecurringDate:
data.isRecurring && data.recurringInterval
? calculateNextRecurringDate(data.date, data.recurringInterval)
: null,
},
});
// Update account balance
await tx.account.update({
where: { id: data.accountId },
data: {
balance: {
increment: netBalanceChange,
},
},
});
return updated;
});
revalidatePath("/dashboard");
revalidatePath(`/account/${data.accountId}`);
return { success: true, data: serializeAmount(transaction) };
} catch (error) {
throw new Error(error.message);
}
}
// Get User Transactions
export async function getUserTransactions(query = {}) {
try {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const user = await db.user.findUnique({
where: { clerkUserId: userId },
});
if (!user) {
throw new Error("User not found");
}
const transactions = await db.transaction.findMany({
where: {
userId: user.id,
...query,
},
include: {
account: true,
},
orderBy: {
date: "desc",
},
});
return { success: true, data: transactions };
} catch (error) {
throw new Error(error.message);
}
}
// Scan Receipt
export async function scanReceipt(file) {
try {
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
// Convert File to ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// Convert ArrayBuffer to Base64
const base64String = Buffer.from(arrayBuffer).toString("base64");
const prompt = `
Analyze this receipt image and extract the following information in JSON format:
- Total amount (just the number)
- Date (in ISO format)
- Description or items purchased (brief summary)
- Merchant/store name
- Suggested category (one of: housing,transportation,groceries,utilities,entertainment,food,shopping,healthcare,education,personal,travel,insurance,gifts,bills,other-expense )
Only respond with valid JSON in this exact format:
{
"amount": number,
"date": "ISO date string",
"description": "string",
"merchantName": "string",
"category": "string"
}
If its not a recipt, return an empty object
`;
const result = await model.generateContent([
{
inlineData: {
data: base64String,
mimeType: file.type,
},
},
prompt,
]);
const response = await result.response;
const text = response.text();
const cleanedText = text.replace(/```(?:json)?\n?/g, "").trim();
try {
const data = JSON.parse(cleanedText);
return {
amount: parseFloat(data.amount),
date: new Date(data.date),
description: data.description,
category: data.category,
merchantName: data.merchantName,
};
} catch (parseError) {
console.error("Error parsing JSON response:", parseError);
throw new Error("Invalid response format from Gemini");
}
} catch (error) {
console.error("Error scanning receipt:", error);
throw new Error("Failed to scan receipt");
}
}
// Helper function to calculate next recurring date
function calculateNextRecurringDate(startDate, interval) {
const date = new Date(startDate);
switch (interval) {
case "DAILY":
date.setDate(date.getDate() + 1);
break;
case "WEEKLY":
date.setDate(date.getDate() + 7);
break;
case "MONTHLY":
date.setMonth(date.getMonth() + 1);
break;
case "YEARLY":
date.setFullYear(date.getFullYear() + 1);
break;
}
return date;
}
================================================
FILE: app/(auth)/layout.js
================================================
const AuthLayout = ({ children }) => {
return <div className="flex justify-center pt-40">{children}</div>;
};
export default AuthLayout;
================================================
FILE: app/(auth)/sign-in/[[...sign-in]]/page.jsx
================================================
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}
================================================
FILE: app/(auth)/sign-up/[[...sign-up]]/page.jsx
================================================
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}
================================================
FILE: app/(main)/account/[id]/page.jsx
================================================
import { Suspense } from "react";
import { getAccountWithTransactions } from "@/actions/account";
import { BarLoader } from "react-spinners";
import { TransactionTable } from "../_components/transaction-table";
import { notFound } from "next/navigation";
import { AccountChart } from "../_components/account-chart";
export default async function AccountPage({ params }) {
const accountData = await getAccountWithTransactions(params.id);
if (!accountData) {
notFound();
}
const { transactions, ...account } = accountData;
return (
<div className="space-y-8 px-5">
<div className="flex gap-4 items-end justify-between">
<div>
<h1 className="text-5xl sm:text-6xl font-bold tracking-tight gradient-title capitalize">
{account.name}
</h1>
<p className="text-muted-foreground">
{account.type.charAt(0) + account.type.slice(1).toLowerCase()}{" "}
Account
</p>
</div>
<div className="text-right pb-2">
<div className="text-xl sm:text-2xl font-bold">
${parseFloat(account.balance).toFixed(2)}
</div>
<p className="text-sm text-muted-foreground">
{account._count.transactions} Transactions
</p>
</div>
</div>
{/* Chart Section */}
<Suspense
fallback={<BarLoader className="mt-4" width={"100%"} color="#9333ea" />}
>
<AccountChart transactions={transactions} />
</Suspense>
{/* Transactions Table */}
<Suspense
fallback={<BarLoader className="mt-4" width={"100%"} color="#9333ea" />}
>
<TransactionTable transactions={transactions} />
</Suspense>
</div>
);
}
================================================
FILE: app/(main)/account/_components/account-chart.jsx
================================================
"use client";
import { useState, useMemo } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { format, subDays, startOfDay, endOfDay } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const DATE_RANGES = {
"7D": { label: "Last 7 Days", days: 7 },
"1M": { label: "Last Month", days: 30 },
"3M": { label: "Last 3 Months", days: 90 },
"6M": { label: "Last 6 Months", days: 180 },
ALL: { label: "All Time", days: null },
};
export function AccountChart({ transactions }) {
const [dateRange, setDateRange] = useState("1M");
const filteredData = useMemo(() => {
const range = DATE_RANGES[dateRange];
const now = new Date();
const startDate = range.days
? startOfDay(subDays(now, range.days))
: startOfDay(new Date(0));
// Filter transactions within date range
const filtered = transactions.filter(
(t) => new Date(t.date) >= startDate && new Date(t.date) <= endOfDay(now)
);
// Group transactions by date
const grouped = filtered.reduce((acc, transaction) => {
const date = format(new Date(transaction.date), "MMM dd");
if (!acc[date]) {
acc[date] = { date, income: 0, expense: 0 };
}
if (transaction.type === "INCOME") {
acc[date].income += transaction.amount;
} else {
acc[date].expense += transaction.amount;
}
return acc;
}, {});
// Convert to array and sort by date
return Object.values(grouped).sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
}, [transactions, dateRange]);
// Calculate totals for the selected period
const totals = useMemo(() => {
return filteredData.reduce(
(acc, day) => ({
income: acc.income + day.income,
expense: acc.expense + day.expense,
}),
{ income: 0, expense: 0 }
);
}, [filteredData]);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<CardTitle className="text-base font-normal">
Transaction Overview
</CardTitle>
<Select defaultValue={dateRange} onValueChange={setDateRange}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
{Object.entries(DATE_RANGES).map(([key, { label }]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<div className="flex justify-around mb-6 text-sm">
<div className="text-center">
<p className="text-muted-foreground">Total Income</p>
<p className="text-lg font-bold text-green-500">
${totals.income.toFixed(2)}
</p>
</div>
<div className="text-center">
<p className="text-muted-foreground">Total Expenses</p>
<p className="text-lg font-bold text-red-500">
${totals.expense.toFixed(2)}
</p>
</div>
<div className="text-center">
<p className="text-muted-foreground">Net</p>
<p
className={`text-lg font-bold ${
totals.income - totals.expense >= 0
? "text-green-500"
: "text-red-500"
}`}
>
${(totals.income - totals.expense).toFixed(2)}
</p>
</div>
</div>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={filteredData}
margin={{ top: 10, right: 10, left: 10, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
formatter={(value) => [`$${value}`, undefined]}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
/>
<Legend />
<Bar
dataKey="income"
name="Income"
fill="#22c55e"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="expense"
name="Expense"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
================================================
FILE: app/(main)/account/_components/no-pagination-transaction-table.jsx
================================================
"use client";
import { useState, useEffect, useMemo } from "react";
import {
ChevronDown,
ChevronUp,
MoreHorizontal,
Trash,
Search,
X,
RefreshCw,
Clock,
} from "lucide-react";
import { format } from "date-fns";
import { toast } from "sonner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { categoryColors } from "@/data/categories";
import { bulkDeleteTransactions } from "@/actions/account";
import useFetch from "@/hooks/use-fetch";
import { BarLoader } from "react-spinners";
import { useRouter } from "next/navigation";
const RECURRING_INTERVALS = {
DAILY: "Daily",
WEEKLY: "Weekly",
MONTHLY: "Monthly",
YEARLY: "Yearly",
};
export function NoPaginationTransactionTable({ transactions }) {
const [selectedIds, setSelectedIds] = useState([]);
const [sortConfig, setSortConfig] = useState({
field: "date",
direction: "desc",
});
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [recurringFilter, setRecurringFilter] = useState("");
const router = useRouter();
// Memoized filtered and sorted transactions
const filteredAndSortedTransactions = useMemo(() => {
let result = [...transactions];
// Apply search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
result = result.filter((transaction) =>
transaction.description?.toLowerCase().includes(searchLower)
);
}
// Apply type filter
if (typeFilter) {
result = result.filter((transaction) => transaction.type === typeFilter);
}
// Apply recurring filter
if (recurringFilter) {
result = result.filter((transaction) => {
if (recurringFilter === "recurring") return transaction.isRecurring;
return !transaction.isRecurring;
});
}
// Apply sorting
result.sort((a, b) => {
let comparison = 0;
switch (sortConfig.field) {
case "date":
comparison = new Date(a.date) - new Date(b.date);
break;
case "amount":
comparison = a.amount - b.amount;
break;
case "category":
comparison = a.category.localeCompare(b.category);
break;
default:
comparison = 0;
}
return sortConfig.direction === "asc" ? comparison : -comparison;
});
return result;
}, [transactions, searchTerm, typeFilter, recurringFilter, sortConfig]);
const handleSort = (field) => {
setSortConfig((current) => ({
field,
direction:
current.field === field && current.direction === "asc" ? "desc" : "asc",
}));
};
const handleSelect = (id) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
);
};
const handleSelectAll = () => {
setSelectedIds((current) =>
current.length === filteredAndSortedTransactions.length
? []
: filteredAndSortedTransactions.map((t) => t.id)
);
};
const {
loading: deleteLoading,
fn: deleteFn,
data: deleted,
} = useFetch(bulkDeleteTransactions);
const handleBulkDelete = async () => {
if (
!window.confirm(
`Are you sure you want to delete ${selectedIds.length} transactions?`
)
)
return;
deleteFn(selectedIds);
};
useEffect(() => {
if (deleted && !deleteLoading) {
toast.error("Transactions deleted successfully");
}
}, [deleted, deleteLoading]);
const handleClearFilters = () => {
setSearchTerm("");
setTypeFilter("");
setRecurringFilter("");
setSelectedIds([]);
};
return (
<div className="space-y-4">
{deleteLoading && (
<BarLoader className="mt-4" width={"100%"} color="#9333ea" />
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INCOME">Income</SelectItem>
<SelectItem value="EXPENSE">Expense</SelectItem>
</SelectContent>
</Select>
<Select
value={recurringFilter}
onValueChange={(value) => {
setRecurringFilter(value);
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="All Transactions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="recurring">Recurring Only</SelectItem>
<SelectItem value="non-recurring">Non-recurring Only</SelectItem>
</SelectContent>
</Select>
{/* Bulk Actions */}
{selectedIds.length > 0 && (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
>
<Trash className="h-4 w-4 mr-2" />
Delete Selected ({selectedIds.length})
</Button>
</div>
)}
{(searchTerm || typeFilter || recurringFilter) && (
<Button
variant="outline"
size="icon"
onClick={handleClearFilters}
title="Clear filters"
>
<X className="h-4 w-5" />
</Button>
)}
</div>
</div>
{/* Transactions Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={
selectedIds.length ===
filteredAndSortedTransactions.length &&
filteredAndSortedTransactions.length > 0
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead
className="cursor-pointer"
onClick={() => handleSort("date")}
>
<div className="flex items-center">
Date
{sortConfig.field === "date" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead>Description</TableHead>
<TableHead
className="cursor-pointer"
onClick={() => handleSort("category")}
>
<div className="flex items-center">
Category
{sortConfig.field === "category" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead
className="cursor-pointer text-right"
onClick={() => handleSort("amount")}
>
<div className="flex items-center justify-end">
Amount
{sortConfig.field === "amount" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead>Recurring</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedTransactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
No transactions found
</TableCell>
</TableRow>
) : (
filteredAndSortedTransactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(transaction.id)}
onCheckedChange={() => handleSelect(transaction.id)}
/>
</TableCell>
<TableCell>
{format(new Date(transaction.date), "PP")}
</TableCell>
<TableCell>{transaction.description}</TableCell>
<TableCell className="capitalize">
<span
style={{
background: categoryColors[transaction.category],
}}
className="px-2 py-1 rounded text-white text-sm"
>
{transaction.category}
</span>
</TableCell>
<TableCell
className={cn(
"text-right font-medium",
transaction.type === "EXPENSE"
? "text-red-500"
: "text-green-500"
)}
>
{transaction.type === "EXPENSE" ? "-" : "+"}$
{transaction.amount.toFixed(2)}
</TableCell>
<TableCell>
{transaction.isRecurring ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="gap-1 bg-purple-100 text-purple-700 hover:bg-purple-200"
>
<RefreshCw className="h-3 w-3" />
{
RECURRING_INTERVALS[
transaction.recurringInterval
]
}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<div className="font-medium">Next Date:</div>
<div>
{format(
new Date(transaction.nextRecurringDate),
"PPP"
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
One-time
</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(
`/transaction/create?edit=${transaction.id}`
)
}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteFn([transaction.id])}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
================================================
FILE: app/(main)/account/_components/transaction-table.jsx
================================================
"use client";
import { useState, useEffect, useMemo } from "react";
import {
ChevronDown,
ChevronUp,
MoreHorizontal,
Trash,
Search,
X,
ChevronLeft,
ChevronRight,
RefreshCw,
Clock,
} from "lucide-react";
import { format } from "date-fns";
import { toast } from "sonner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { categoryColors } from "@/data/categories";
import { bulkDeleteTransactions } from "@/actions/account";
import useFetch from "@/hooks/use-fetch";
import { BarLoader } from "react-spinners";
import { useRouter } from "next/navigation";
const ITEMS_PER_PAGE = 10;
const RECURRING_INTERVALS = {
DAILY: "Daily",
WEEKLY: "Weekly",
MONTHLY: "Monthly",
YEARLY: "Yearly",
};
export function TransactionTable({ transactions }) {
const [selectedIds, setSelectedIds] = useState([]);
const [sortConfig, setSortConfig] = useState({
field: "date",
direction: "desc",
});
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [recurringFilter, setRecurringFilter] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const router = useRouter();
// Memoized filtered and sorted transactions
const filteredAndSortedTransactions = useMemo(() => {
let result = [...transactions];
// Apply search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
result = result.filter((transaction) =>
transaction.description?.toLowerCase().includes(searchLower)
);
}
// Apply type filter
if (typeFilter) {
result = result.filter((transaction) => transaction.type === typeFilter);
}
// Apply recurring filter
if (recurringFilter) {
result = result.filter((transaction) => {
if (recurringFilter === "recurring") return transaction.isRecurring;
return !transaction.isRecurring;
});
}
// Apply sorting
result.sort((a, b) => {
let comparison = 0;
switch (sortConfig.field) {
case "date":
comparison = new Date(a.date) - new Date(b.date);
break;
case "amount":
comparison = a.amount - b.amount;
break;
case "category":
comparison = a.category.localeCompare(b.category);
break;
default:
comparison = 0;
}
return sortConfig.direction === "asc" ? comparison : -comparison;
});
return result;
}, [transactions, searchTerm, typeFilter, recurringFilter, sortConfig]);
// Pagination calculations
const totalPages = Math.ceil(
filteredAndSortedTransactions.length / ITEMS_PER_PAGE
);
const paginatedTransactions = useMemo(() => {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredAndSortedTransactions.slice(
startIndex,
startIndex + ITEMS_PER_PAGE
);
}, [filteredAndSortedTransactions, currentPage]);
const handleSort = (field) => {
setSortConfig((current) => ({
field,
direction:
current.field === field && current.direction === "asc" ? "desc" : "asc",
}));
};
const handleSelect = (id) => {
setSelectedIds((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id]
);
};
const handleSelectAll = () => {
setSelectedIds((current) =>
current.length === paginatedTransactions.length
? []
: paginatedTransactions.map((t) => t.id)
);
};
const {
loading: deleteLoading,
fn: deleteFn,
data: deleted,
} = useFetch(bulkDeleteTransactions);
const handleBulkDelete = async () => {
if (
!window.confirm(
`Are you sure you want to delete ${selectedIds.length} transactions?`
)
)
return;
deleteFn(selectedIds);
};
useEffect(() => {
if (deleted && !deleteLoading) {
toast.error("Transactions deleted successfully");
}
}, [deleted, deleteLoading]);
const handleClearFilters = () => {
setSearchTerm("");
setTypeFilter("");
setRecurringFilter("");
setCurrentPage(1);
};
const handlePageChange = (newPage) => {
setCurrentPage(newPage);
setSelectedIds([]); // Clear selections on page change
};
return (
<div className="space-y-4">
{deleteLoading && (
<BarLoader className="mt-4" width={"100%"} color="#9333ea" />
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search transactions..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-8"
/>
</div>
<div className="flex gap-2">
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="INCOME">Income</SelectItem>
<SelectItem value="EXPENSE">Expense</SelectItem>
</SelectContent>
</Select>
<Select
value={recurringFilter}
onValueChange={(value) => {
setRecurringFilter(value);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="All Transactions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="recurring">Recurring Only</SelectItem>
<SelectItem value="non-recurring">Non-recurring Only</SelectItem>
</SelectContent>
</Select>
{/* Bulk Actions */}
{selectedIds.length > 0 && (
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
>
<Trash className="h-4 w-4 mr-2" />
Delete Selected ({selectedIds.length})
</Button>
</div>
)}
{(searchTerm || typeFilter || recurringFilter) && (
<Button
variant="outline"
size="icon"
onClick={handleClearFilters}
title="Clear filters"
>
<X className="h-4 w-5" />
</Button>
)}
</div>
</div>
{/* Transactions Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={
selectedIds.length === paginatedTransactions.length &&
paginatedTransactions.length > 0
}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead
className="cursor-pointer"
onClick={() => handleSort("date")}
>
<div className="flex items-center">
Date
{sortConfig.field === "date" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead>Description</TableHead>
<TableHead
className="cursor-pointer"
onClick={() => handleSort("category")}
>
<div className="flex items-center">
Category
{sortConfig.field === "category" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead
className="cursor-pointer text-right"
onClick={() => handleSort("amount")}
>
<div className="flex items-center justify-end">
Amount
{sortConfig.field === "amount" &&
(sortConfig.direction === "asc" ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : (
<ChevronDown className="ml-1 h-4 w-4" />
))}
</div>
</TableHead>
<TableHead>Recurring</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{paginatedTransactions.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
No transactions found
</TableCell>
</TableRow>
) : (
paginatedTransactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(transaction.id)}
onCheckedChange={() => handleSelect(transaction.id)}
/>
</TableCell>
<TableCell>
{format(new Date(transaction.date), "PP")}
</TableCell>
<TableCell>{transaction.description}</TableCell>
<TableCell className="capitalize">
<span
style={{
background: categoryColors[transaction.category],
}}
className="px-2 py-1 rounded text-white text-sm"
>
{transaction.category}
</span>
</TableCell>
<TableCell
className={cn(
"text-right font-medium",
transaction.type === "EXPENSE"
? "text-red-500"
: "text-green-500"
)}
>
{transaction.type === "EXPENSE" ? "-" : "+"}$
{transaction.amount.toFixed(2)}
</TableCell>
<TableCell>
{transaction.isRecurring ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="gap-1 bg-purple-100 text-purple-700 hover:bg-purple-200"
>
<RefreshCw className="h-3 w-3" />
{
RECURRING_INTERVALS[
transaction.recurringInterval
]
}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<div className="font-medium">Next Date:</div>
<div>
{format(
new Date(transaction.nextRecurringDate),
"PPP"
)}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
One-time
</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(
`/transaction/create?edit=${transaction.id}`
)
}
>
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteFn([transaction.id])}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="icon"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
================================================
FILE: app/(main)/dashboard/_components/account-card.jsx
================================================
"use client";
import { ArrowUpRight, ArrowDownRight, CreditCard } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useEffect } from "react";
import useFetch from "@/hooks/use-fetch";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { updateDefaultAccount } from "@/actions/account";
import { toast } from "sonner";
export function AccountCard({ account }) {
const { name, type, balance, id, isDefault } = account;
const {
loading: updateDefaultLoading,
fn: updateDefaultFn,
data: updatedAccount,
error,
} = useFetch(updateDefaultAccount);
const handleDefaultChange = async (event) => {
event.preventDefault(); // Prevent navigation
if (isDefault) {
toast.warning("You need atleast 1 default account");
return; // Don't allow toggling off the default account
}
await updateDefaultFn(id);
};
useEffect(() => {
if (updatedAccount?.success) {
toast.success("Default account updated successfully");
}
}, [updatedAccount]);
useEffect(() => {
if (error) {
toast.error(error.message || "Failed to update default account");
}
}, [error]);
return (
<Card className="hover:shadow-md transition-shadow group relative">
<Link href={`/account/${id}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium capitalize">
{name}
</CardTitle>
<Switch
checked={isDefault}
onClick={handleDefaultChange}
disabled={updateDefaultLoading}
/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${parseFloat(balance).toFixed(2)}
</div>
<p className="text-xs text-muted-foreground">
{type.charAt(0) + type.slice(1).toLowerCase()} Account
</p>
</CardContent>
<CardFooter className="flex justify-between text-sm text-muted-foreground">
<div className="flex items-center">
<ArrowUpRight className="mr-1 h-4 w-4 text-green-500" />
Income
</div>
<div className="flex items-center">
<ArrowDownRight className="mr-1 h-4 w-4 text-red-500" />
Expense
</div>
</CardFooter>
</Link>
</Card>
);
}
================================================
FILE: app/(main)/dashboard/_components/budget-progress.jsx
================================================
"use client";
import { useState, useEffect } from "react";
import { Pencil, Check, X } from "lucide-react";
import useFetch from "@/hooks/use-fetch";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { updateBudget } from "@/actions/budget";
export function BudgetProgress({ initialBudget, currentExpenses }) {
const [isEditing, setIsEditing] = useState(false);
const [newBudget, setNewBudget] = useState(
initialBudget?.amount?.toString() || ""
);
const {
loading: isLoading,
fn: updateBudgetFn,
data: updatedBudget,
error,
} = useFetch(updateBudget);
const percentUsed = initialBudget
? (currentExpenses / initialBudget.amount) * 100
: 0;
const handleUpdateBudget = async () => {
const amount = parseFloat(newBudget);
if (isNaN(amount) || amount <= 0) {
toast.error("Please enter a valid amount");
return;
}
await updateBudgetFn(amount);
};
const handleCancel = () => {
setNewBudget(initialBudget?.amount?.toString() || "");
setIsEditing(false);
};
useEffect(() => {
if (updatedBudget?.success) {
setIsEditing(false);
toast.success("Budget updated successfully");
}
}, [updatedBudget]);
useEffect(() => {
if (error) {
toast.error(error.message || "Failed to update budget");
}
}, [error]);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex-1">
<CardTitle className="text-sm font-medium">
Monthly Budget (Default Account)
</CardTitle>
<div className="flex items-center gap-2 mt-1">
{isEditing ? (
<div className="flex items-center gap-2">
<Input
type="number"
value={newBudget}
onChange={(e) => setNewBudget(e.target.value)}
className="w-32"
placeholder="Enter amount"
autoFocus
disabled={isLoading}
/>
<Button
variant="ghost"
size="icon"
onClick={handleUpdateBudget}
disabled={isLoading}
>
<Check className="h-4 w-4 text-green-500" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
disabled={isLoading}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
) : (
<>
<CardDescription>
{initialBudget
? `$${currentExpenses.toFixed(
2
)} of $${initialBudget.amount.toFixed(2)} spent`
: "No budget set"}
</CardDescription>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditing(true)}
className="h-6 w-6"
>
<Pencil className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
{initialBudget && (
<div className="space-y-2">
<Progress
value={percentUsed}
extraStyles={`${
// add to Progress component
percentUsed >= 90
? "bg-red-500"
: percentUsed >= 75
? "bg-yellow-500"
: "bg-green-500"
}`}
/>
<p className="text-xs text-muted-foreground text-right">
{percentUsed.toFixed(1)}% used
</p>
</div>
)}
</CardContent>
</Card>
);
}
================================================
FILE: app/(main)/dashboard/_components/transaction-overview.jsx
================================================
"use client";
import { useState } from "react";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from "recharts";
import { format } from "date-fns";
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const COLORS = [
"#FF6B6B",
"#4ECDC4",
"#45B7D1",
"#96CEB4",
"#FFEEAD",
"#D4A5A5",
"#9FA8DA",
];
export function DashboardOverview({ accounts, transactions }) {
const [selectedAccountId, setSelectedAccountId] = useState(
accounts.find((a) => a.isDefault)?.id || accounts[0]?.id
);
// Filter transactions for selected account
const accountTransactions = transactions.filter(
(t) => t.accountId === selectedAccountId
);
// Get recent transactions (last 5)
const recentTransactions = accountTransactions
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 5);
// Calculate expense breakdown for current month
const currentDate = new Date();
const currentMonthExpenses = accountTransactions.filter((t) => {
const transactionDate = new Date(t.date);
return (
t.type === "EXPENSE" &&
transactionDate.getMonth() === currentDate.getMonth() &&
transactionDate.getFullYear() === currentDate.getFullYear()
);
});
// Group expenses by category
const expensesByCategory = currentMonthExpenses.reduce((acc, transaction) => {
const category = transaction.category;
if (!acc[category]) {
acc[category] = 0;
}
acc[category] += transaction.amount;
return acc;
}, {});
// Format data for pie chart
const pieChartData = Object.entries(expensesByCategory).map(
([category, amount]) => ({
name: category,
value: amount,
})
);
return (
<div className="grid gap-4 md:grid-cols-2">
{/* Recent Transactions Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-base font-normal">
Recent Transactions
</CardTitle>
<Select
value={selectedAccountId}
onValueChange={setSelectedAccountId}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentTransactions.length === 0 ? (
<p className="text-center text-muted-foreground py-4">
No recent transactions
</p>
) : (
recentTransactions.map((transaction) => (
<div
key={transaction.id}
className="flex items-center justify-between"
>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{transaction.description || "Untitled Transaction"}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(transaction.date), "PP")}
</p>
</div>
<div className="flex items-center gap-2">
<div
className={cn(
"flex items-center",
transaction.type === "EXPENSE"
? "text-red-500"
: "text-green-500"
)}
>
{transaction.type === "EXPENSE" ? (
<ArrowDownRight className="mr-1 h-4 w-4" />
) : (
<ArrowUpRight className="mr-1 h-4 w-4" />
)}
${transaction.amount.toFixed(2)}
</div>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Expense Breakdown Card */}
<Card>
<CardHeader>
<CardTitle className="text-base font-normal">
Monthly Expense Breakdown
</CardTitle>
</CardHeader>
<CardContent className="p-0 pb-5">
{pieChartData.length === 0 ? (
<p className="text-center text-muted-foreground py-4">
No expenses this month
</p>
) : (
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, value }) => `${name}: $${value.toFixed(2)}`}
>
{pieChartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value) => `$${value.toFixed(2)}`}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
);
}
================================================
FILE: app/(main)/dashboard/layout.js
================================================
import DashboardPage from "./page";
import { BarLoader } from "react-spinners";
import { Suspense } from "react";
export default function Layout() {
return (
<div className="px-5">
<div className="flex items-center justify-between mb-5">
<h1 className="text-6xl font-bold tracking-tight gradient-title">
Dashboard
</h1>
</div>
<Suspense
fallback={<BarLoader className="mt-4" width={"100%"} color="#9333ea" />}
>
<DashboardPage />
</Suspense>
</div>
);
}
================================================
FILE: app/(main)/dashboard/page.jsx
================================================
import { Suspense } from "react";
import { getUserAccounts } from "@/actions/dashboard";
import { getDashboardData } from "@/actions/dashboard";
import { getCurrentBudget } from "@/actions/budget";
import { AccountCard } from "./_components/account-card";
import { CreateAccountDrawer } from "@/components/create-account-drawer";
import { BudgetProgress } from "./_components/budget-progress";
import { Card, CardContent } from "@/components/ui/card";
import { Plus } from "lucide-react";
import { DashboardOverview } from "./_components/transaction-overview";
export default async function DashboardPage() {
const [accounts, transactions] = await Promise.all([
getUserAccounts(),
getDashboardData(),
]);
const defaultAccount = accounts?.find((account) => account.isDefault);
// Get budget for default account
let budgetData = null;
if (defaultAccount) {
budgetData = await getCurrentBudget(defaultAccount.id);
}
return (
<div className="space-y-8">
{/* Budget Progress */}
<BudgetProgress
initialBudget={budgetData?.budget}
currentExpenses={budgetData?.currentExpenses || 0}
/>
{/* Dashboard Overview */}
<DashboardOverview
accounts={accounts}
transactions={transactions || []}
/>
{/* Accounts Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<CreateAccountDrawer>
<Card className="hover:shadow-md transition-shadow cursor-pointer border-dashed">
<CardContent className="flex flex-col items-center justify-center text-muted-foreground h-full pt-5">
<Plus className="h-10 w-10 mb-2" />
<p className="text-sm font-medium">Add New Account</p>
</CardContent>
</Card>
</CreateAccountDrawer>
{accounts.length > 0 &&
accounts?.map((account) => (
<AccountCard key={account.id} account={account} />
))}
</div>
</div>
);
}
================================================
FILE: app/(main)/layout.js
================================================
import React from "react";
const MainLayout = ({ children }) => {
return <div className="container mx-auto my-32">{children}</div>;
};
export default MainLayout;
================================================
FILE: app/(main)/transaction/_components/recipt-scanner.jsx
================================================
"use client";
import { useRef, useEffect } from "react";
import { Camera, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import useFetch from "@/hooks/use-fetch";
import { scanReceipt } from "@/actions/transaction";
export function ReceiptScanner({ onScanComplete }) {
const fileInputRef = useRef(null);
const {
loading: scanReceiptLoading,
fn: scanReceiptFn,
data: scannedData,
} = useFetch(scanReceipt);
const handleReceiptScan = async (file) => {
if (file.size > 5 * 1024 * 1024) {
toast.error("File size should be less than 5MB");
return;
}
await scanReceiptFn(file);
};
useEffect(() => {
if (scannedData && !scanReceiptLoading) {
onScanComplete(scannedData);
toast.success("Receipt scanned successfully");
}
}, [scanReceiptLoading, scannedData]);
return (
<div className="flex items-center gap-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
capture="environment"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleReceiptScan(file);
}}
/>
<Button
type="button"
variant="outline"
className="w-full h-10 bg-gradient-to-br from-orange-500 via-pink-500 to-purple-500 animate-gradient hover:opacity-90 transition-opacity text-white hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={scanReceiptLoading}
>
{scanReceiptLoading ? (
<>
<Loader2 className="mr-2 animate-spin" />
<span>Scanning Receipt...</span>
</>
) : (
<>
<Camera className="mr-2" />
<span>Scan Receipt with AI</span>
</>
)}
</Button>
</div>
);
}
================================================
FILE: app/(main)/transaction/_components/transaction-form.jsx
================================================
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { CalendarIcon, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { useRouter, useSearchParams } from "next/navigation";
import useFetch from "@/hooks/use-fetch";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { CreateAccountDrawer } from "@/components/create-account-drawer";
import { cn } from "@/lib/utils";
import { createTransaction, updateTransaction } from "@/actions/transaction";
import { transactionSchema } from "@/app/lib/schema";
import { ReceiptScanner } from "./recipt-scanner";
export function AddTransactionForm({
accounts,
categories,
editMode = false,
initialData = null,
}) {
const router = useRouter();
const searchParams = useSearchParams();
const editId = searchParams.get("edit");
const {
register,
handleSubmit,
formState: { errors },
watch,
setValue,
getValues,
reset,
} = useForm({
resolver: zodResolver(transactionSchema),
defaultValues:
editMode && initialData
? {
type: initialData.type,
amount: initialData.amount.toString(),
description: initialData.description,
accountId: initialData.accountId,
category: initialData.category,
date: new Date(initialData.date),
isRecurring: initialData.isRecurring,
...(initialData.recurringInterval && {
recurringInterval: initialData.recurringInterval,
}),
}
: {
type: "EXPENSE",
amount: "",
description: "",
accountId: accounts.find((ac) => ac.isDefault)?.id,
date: new Date(),
isRecurring: false,
},
});
const {
loading: transactionLoading,
fn: transactionFn,
data: transactionResult,
} = useFetch(editMode ? updateTransaction : createTransaction);
const onSubmit = (data) => {
const formData = {
...data,
amount: parseFloat(data.amount),
};
if (editMode) {
transactionFn(editId, formData);
} else {
transactionFn(formData);
}
};
const handleScanComplete = (scannedData) => {
if (scannedData) {
setValue("amount", scannedData.amount.toString());
setValue("date", new Date(scannedData.date));
if (scannedData.description) {
setValue("description", scannedData.description);
}
if (scannedData.category) {
setValue("category", scannedData.category);
}
toast.success("Receipt scanned successfully");
}
};
useEffect(() => {
if (transactionResult?.success && !transactionLoading) {
toast.success(
editMode
? "Transaction updated successfully"
: "Transaction created successfully"
);
reset();
router.push(`/account/${transactionResult.data.accountId}`);
}
}, [transactionResult, transactionLoading, editMode]);
const type = watch("type");
const isRecurring = watch("isRecurring");
const date = watch("date");
const filteredCategories = categories.filter(
(category) => category.type === type
);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Receipt Scanner - Only show in create mode */}
{!editMode && <ReceiptScanner onScanComplete={handleScanComplete} />}
{/* Type */}
<div className="space-y-2">
<label className="text-sm font-medium">Type</label>
<Select
onValueChange={(value) => setValue("type", value)}
defaultValue={type}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXPENSE">Expense</SelectItem>
<SelectItem value="INCOME">Income</SelectItem>
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">{errors.type.message}</p>
)}
</div>
{/* Amount and Account */}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Amount</label>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...register("amount")}
/>
{errors.amount && (
<p className="text-sm text-red-500">{errors.amount.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Account</label>
<Select
onValueChange={(value) => setValue("accountId", value)}
defaultValue={getValues("accountId")}
>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name} (${parseFloat(account.balance).toFixed(2)})
</SelectItem>
))}
<CreateAccountDrawer>
<Button
variant="ghost"
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
>
Create Account
</Button>
</CreateAccountDrawer>
</SelectContent>
</Select>
{errors.accountId && (
<p className="text-sm text-red-500">{errors.accountId.message}</p>
)}
</div>
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium">Category</label>
<Select
onValueChange={(value) => setValue("category", value)}
defaultValue={getValues("category")}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{filteredCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category && (
<p className="text-sm text-red-500">{errors.category.message}</p>
)}
</div>
{/* Date */}
<div className="space-y-2">
<label className="text-sm font-medium">Date</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!date && "text-muted-foreground"
)}
>
{date ? format(date, "PPP") : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(date) => setValue("date", date)}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
{errors.date && (
<p className="text-sm text-red-500">{errors.date.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Input placeholder="Enter description" {...register("description")} />
{errors.description && (
<p className="text-sm text-red-500">{errors.description.message}</p>
)}
</div>
{/* Recurring Toggle */}
<div className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<label className="text-base font-medium">Recurring Transaction</label>
<div className="text-sm text-muted-foreground">
Set up a recurring schedule for this transaction
</div>
</div>
<Switch
checked={isRecurring}
onCheckedChange={(checked) => setValue("isRecurring", checked)}
/>
</div>
{/* Recurring Interval */}
{isRecurring && (
<div className="space-y-2">
<label className="text-sm font-medium">Recurring Interval</label>
<Select
onValueChange={(value) => setValue("recurringInterval", value)}
defaultValue={getValues("recurringInterval")}
>
<SelectTrigger>
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DAILY">Daily</SelectItem>
<SelectItem value="WEEKLY">Weekly</SelectItem>
<SelectItem value="MONTHLY">Monthly</SelectItem>
<SelectItem value="YEARLY">Yearly</SelectItem>
</SelectContent>
</Select>
{errors.recurringInterval && (
<p className="text-sm text-red-500">
{errors.recurringInterval.message}
</p>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-4">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" className="w-full" disabled={transactionLoading}>
{transactionLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{editMode ? "Updating..." : "Creating..."}
</>
) : editMode ? (
"Update Transaction"
) : (
"Create Transaction"
)}
</Button>
</div>
</form>
);
}
================================================
FILE: app/(main)/transaction/create/page.jsx
================================================
import { getUserAccounts } from "@/actions/dashboard";
import { defaultCategories } from "@/data/categories";
import { AddTransactionForm } from "../_components/transaction-form";
import { getTransaction } from "@/actions/transaction";
export default async function AddTransactionPage({ searchParams }) {
const accounts = await getUserAccounts();
const editId = searchParams?.edit;
let initialData = null;
if (editId) {
const transaction = await getTransaction(editId);
initialData = transaction;
}
return (
<div className="max-w-3xl mx-auto px-5">
<div className="flex justify-center md:justify-normal mb-8">
<h1 className="text-5xl gradient-title ">Add Transaction</h1>
</div>
<AddTransactionForm
accounts={accounts}
categories={defaultCategories}
editMode={!!editId}
initialData={initialData}
/>
</div>
);
}
================================================
FILE: app/api/inngest/route.js
================================================
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import {
checkBudgetAlerts,
generateMonthlyReports,
processRecurringTransaction,
triggerRecurringTransactions,
} from "@/lib/inngest/function";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [
processRecurringTransaction,
triggerRecurringTransactions,
generateMonthlyReports,
checkBudgetAlerts,
],
});
================================================
FILE: app/api/seed/route.js
================================================
import { seedTransactions } from "@/actions/seed";
export async function GET() {
const result = await seedTransactions();
return Response.json(result);
}
================================================
FILE: app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
html {
scroll-behavior: smooth;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.gradient {
@apply bg-gradient-to-br from-blue-600 to-purple-600;
}
.gradient-title {
@apply gradient font-extrabold tracking-tighter pr-2 pb-2 text-transparent bg-clip-text;
}
}
.hero-image-wrapper {
perspective: 1000px;
}
.hero-image {
/* transform: rotateX(20deg) scale(0.9) translateY(-50); */
transform: rotateX(15deg) scale(1);
transition: transform 0.5s ease-out;
will-change: transform;
}
.hero-image.scrolled {
transform: rotateX(0deg) scale(1) translateY(40px);
}
@keyframes gradientMove {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Add this class */
.animate-gradient {
background-size: 200% 200%;
animation: gradientMove 3s ease infinite;
}
================================================
FILE: app/layout.js
================================================
import { Inter } from "next/font/google";
import "./globals.css";
import Header from "@/components/header";
import { ClerkProvider } from "@clerk/nextjs";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "Welth",
description: "One stop Finance Platform",
};
export default function RootLayout({ children }) {
return (
<ClerkProvider>
<html lang="en">
<head>
<link rel="icon" href="/logo-sm.png" sizes="any" />
</head>
<body className={`${inter.className}`}>
<Header />
<main className="min-h-screen">{children}</main>
<Toaster richColors />
<footer className="bg-blue-50 py-12">
<div className="container mx-auto px-4 text-center text-gray-600">
<p>Made with 💗 by RoadsideCoder</p>
</div>
</footer>
</body>
</html>
</ClerkProvider>
);
}
================================================
FILE: app/lib/schema.js
================================================
import { z } from "zod";
export const accountSchema = z.object({
name: z.string().min(1, "Name is required"),
type: z.enum(["CURRENT", "SAVINGS"]),
balance: z.string().min(1, "Initial balance is required"),
isDefault: z.boolean().default(false),
});
export const transactionSchema = z
.object({
type: z.enum(["INCOME", "EXPENSE"]),
amount: z.string().min(1, "Amount is required"),
description: z.string().optional(),
date: z.date({ required_error: "Date is required" }),
accountId: z.string().min(1, "Account is required"),
category: z.string().min(1, "Category is required"),
isRecurring: z.boolean().default(false),
recurringInterval: z
.enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"])
.optional(),
})
.superRefine((data, ctx) => {
if (data.isRecurring && !data.recurringInterval) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Recurring interval is required for recurring transactions",
path: ["recurringInterval"],
});
}
});
================================================
FILE: app/not-found.jsx
================================================
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[100vh] px-4 text-center">
<h1 className="text-6xl font-bold gradient-title mb-4">404</h1>
<h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
<p className="text-gray-600 mb-8">
Oops! The page you're looking for doesn't exist or has been
moved.
</p>
<Link href="/">
<Button>Return Home</Button>
</Link>
</div>
);
}
================================================
FILE: app/page.js
================================================
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import Image from "next/image";
import {
featuresData,
howItWorksData,
statsData,
testimonialsData,
} from "@/data/landing";
import HeroSection from "@/components/hero";
import Link from "next/link";
const LandingPage = () => {
return (
<div className="min-h-screen bg-white">
{/* Hero Section */}
<HeroSection />
{/* Stats Section */}
<section className="py-20 bg-blue-50">
<div className="container mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{statsData.map((stat, index) => (
<div key={index} className="text-center">
<div className="text-4xl font-bold text-blue-600 mb-2">
{stat.value}
</div>
<div className="text-gray-600">{stat.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-12">
Everything you need to manage your finances
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{featuresData.map((feature, index) => (
<Card className="p-6" key={index}>
<CardContent className="space-y-4 pt-4">
{feature.icon}
<h3 className="text-xl font-semibold">{feature.title}</h3>
<p className="text-gray-600">{feature.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* How It Works Section */}
<section className="py-20 bg-blue-50">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-16">How It Works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
{howItWorksData.map((step, index) => (
<div key={index} className="text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
{step.icon}
</div>
<h3 className="text-xl font-semibold mb-4">{step.title}</h3>
<p className="text-gray-600">{step.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Testimonials Section */}
<section id="testimonials" className="py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-16">
What Our Users Say
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{testimonialsData.map((testimonial, index) => (
<Card key={index} className="p-6">
<CardContent className="pt-4">
<div className="flex items-center mb-4">
<Image
src={testimonial.image}
alt={testimonial.name}
width={40}
height={40}
className="rounded-full"
/>
<div className="ml-4">
<div className="font-semibold">{testimonial.name}</div>
<div className="text-sm text-gray-600">
{testimonial.role}
</div>
</div>
</div>
<p className="text-gray-600">{testimonial.quote}</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 bg-blue-600">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold text-white mb-4">
Ready to Take Control of Your Finances?
</h2>
<p className="text-blue-100 mb-8 max-w-2xl mx-auto">
Join thousands of users who are already managing their finances
smarter with Welth
</p>
<Link href="/dashboard">
<Button
size="lg"
className="bg-white text-blue-600 hover:bg-blue-50 animate-bounce"
>
Start Free Trial
</Button>
</Link>
</div>
</section>
</div>
);
};
export default LandingPage;
================================================
FILE: components/create-account-drawer.jsx
================================================
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import useFetch from "@/hooks/use-fetch";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
DrawerClose,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { createAccount } from "@/actions/dashboard";
import { accountSchema } from "@/app/lib/schema";
export function CreateAccountDrawer({ children }) {
const [open, setOpen] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
reset,
} = useForm({
resolver: zodResolver(accountSchema),
defaultValues: {
name: "",
type: "CURRENT",
balance: "",
isDefault: false,
},
});
const {
loading: createAccountLoading,
fn: createAccountFn,
error,
data: newAccount,
} = useFetch(createAccount);
const onSubmit = async (data) => {
await createAccountFn(data);
};
useEffect(() => {
if (newAccount) {
toast.success("Account created successfully");
reset();
setOpen(false);
}
}, [newAccount, reset]);
useEffect(() => {
if (error) {
toast.error(error.message || "Failed to create account");
}
}, [error]);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Create New Account</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="name"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Account Name
</label>
<Input
id="name"
placeholder="e.g., Main Checking"
{...register("name")}
/>
{errors.name && (
<p className="text-sm text-red-500">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="type"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Account Type
</label>
<Select
onValueChange={(value) => setValue("type", value)}
defaultValue={watch("type")}
>
<SelectTrigger id="type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CURRENT">Current</SelectItem>
<SelectItem value="SAVINGS">Savings</SelectItem>
</SelectContent>
</Select>
{errors.type && (
<p className="text-sm text-red-500">{errors.type.message}</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="balance"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Initial Balance
</label>
<Input
id="balance"
type="number"
step="0.01"
placeholder="0.00"
{...register("balance")}
/>
{errors.balance && (
<p className="text-sm text-red-500">{errors.balance.message}</p>
)}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<label
htmlFor="isDefault"
className="text-base font-medium cursor-pointer"
>
Set as Default
</label>
<p className="text-sm text-muted-foreground">
This account will be selected by default for transactions
</p>
</div>
<Switch
id="isDefault"
checked={watch("isDefault")}
onCheckedChange={(checked) => setValue("isDefault", checked)}
/>
</div>
<div className="flex gap-4 pt-4">
<DrawerClose asChild>
<Button type="button" variant="outline" className="flex-1">
Cancel
</Button>
</DrawerClose>
<Button
type="submit"
className="flex-1"
disabled={createAccountLoading}
>
{createAccountLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Account"
)}
</Button>
</div>
</form>
</div>
</DrawerContent>
</Drawer>
);
}
================================================
FILE: components/header.jsx
================================================
import React from "react";
import { Button } from "./ui/button";
import { PenBox, LayoutDashboard } from "lucide-react";
import Link from "next/link";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
import { checkUser } from "@/lib/checkUser";
import Image from "next/image";
const Header = async () => {
await checkUser();
return (
<header className="fixed top-0 w-full bg-white/80 backdrop-blur-md z-50 border-b">
<nav className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/">
<Image
src={"/logo.png"}
alt="Welth Logo"
width={200}
height={60}
className="h-12 w-auto object-contain"
/>
</Link>
{/* Navigation Links - Different for signed in/out users */}
<div className="hidden md:flex items-center space-x-8">
<SignedOut>
<a href="#features" className="text-gray-600 hover:text-blue-600">
Features
</a>
<a
href="#testimonials"
className="text-gray-600 hover:text-blue-600"
>
Testimonials
</a>
</SignedOut>
</div>
{/* Action Buttons */}
<div className="flex items-center space-x-4">
<SignedIn>
<Link
href="/dashboard"
className="text-gray-600 hover:text-blue-600 flex items-center gap-2"
>
<Button variant="outline">
<LayoutDashboard size={18} />
<span className="hidden md:inline">Dashboard</span>
</Button>
</Link>
<a href="/transaction/create">
<Button className="flex items-center gap-2">
<PenBox size={18} />
<span className="hidden md:inline">Add Transaction</span>
</Button>
</a>
</SignedIn>
<SignedOut>
<SignInButton forceRedirectUrl="/dashboard">
<Button variant="outline">Login</Button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton
appearance={{
elements: {
avatarBox: "w-10 h-10",
},
}}
/>
</SignedIn>
</div>
</nav>
</header>
);
};
export default Header;
================================================
FILE: components/hero.jsx
================================================
"use client";
import React, { useEffect, useRef } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Link from "next/link";
const HeroSection = () => {
const imageRef = useRef(null);
useEffect(() => {
const imageElement = imageRef.current;
const handleScroll = () => {
const scrollPosition = window.scrollY;
const scrollThreshold = 100;
if (scrollPosition > scrollThreshold) {
imageElement.classList.add("scrolled");
} else {
imageElement.classList.remove("scrolled");
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<section className="pt-40 pb-20 px-4">
<div className="container mx-auto text-center">
<h1 className="text-5xl md:text-8xl lg:text-[105px] pb-6 gradient-title">
Manage Your Finances <br /> with Intelligence
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
An AI-powered financial management platform that helps you track,
analyze, and optimize your spending with real-time insights.
</p>
<div className="flex justify-center space-x-4">
<Link href="/dashboard">
<Button size="lg" className="px-8">
Get Started
</Button>
</Link>
<Link href="https://www.youtube.com/roadsidecoder">
<Button size="lg" variant="outline" className="px-8">
Watch Demo
</Button>
</Link>
</div>
<div className="hero-image-wrapper mt-5 md:mt-0">
<div ref={imageRef} className="hero-image">
<Image
src="/banner.jpeg"
width={1280}
height={720}
alt="Dashboard Preview"
className="rounded-lg shadow-2xl border mx-auto"
priority
/>
</div>
</div>
</div>
</section>
);
};
export default HeroSection;
================================================
FILE: components/ui/badge.jsx
================================================
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
================================================
FILE: components/ui/button.jsx
================================================
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
================================================
FILE: components/ui/calendar.jsx
================================================
"use client";
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
return (
(<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props} />)
);
}
Calendar.displayName = "Calendar"
export { Calendar }
================================================
FILE: components/ui/card.jsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
================================================
FILE: components/ui/checkbox.jsx
================================================
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
================================================
FILE: components/ui/drawer.jsx
================================================
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} />
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} />
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
================================================
FILE: components/ui/dropdown-menu.jsx
================================================
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
================================================
FILE: components/ui/input.jsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }
================================================
FILE: components/ui/popover.jsx
================================================
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
================================================
FILE: components/ui/progress.jsx
================================================
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef(
({ className, value, extraStyles, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={`h-full w-full flex-1 bg-primary transition-all ${extraStyles}`}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
================================================
FILE: components/ui/select.jsx
================================================
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
================================================
FILE: components/ui/sonner.jsx
================================================
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
(<Sonner
theme={theme}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props} />)
);
}
export { Toaster }
================================================
FILE: components/ui/switch.jsx
================================================
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
================================================
FILE: components/ui/table.jsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
================================================
FILE: components/ui/tooltip.jsx
================================================
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
================================================
FILE: data/categories.js
================================================
export const defaultCategories = [
// Income Categories
{
id: "salary",
name: "Salary",
type: "INCOME",
color: "#22c55e", // green-500
icon: "Wallet",
},
{
id: "freelance",
name: "Freelance",
type: "INCOME",
color: "#06b6d4", // cyan-500
icon: "Laptop",
},
{
id: "investments",
name: "Investments",
type: "INCOME",
color: "#6366f1", // indigo-500
icon: "TrendingUp",
},
{
id: "business",
name: "Business",
type: "INCOME",
color: "#ec4899", // pink-500
icon: "Building",
},
{
id: "rental",
name: "Rental",
type: "INCOME",
color: "#f59e0b", // amber-500
icon: "Home",
},
{
id: "other-income",
name: "Other Income",
type: "INCOME",
color: "#64748b", // slate-500
icon: "Plus",
},
// Expense Categories
{
id: "housing",
name: "Housing",
type: "EXPENSE",
color: "#ef4444", // red-500
icon: "Home",
subcategories: ["Rent", "Mortgage", "Property Tax", "Maintenance"],
},
{
id: "transportation",
name: "Transportation",
type: "EXPENSE",
color: "#f97316", // orange-500
icon: "Car",
subcategories: ["Fuel", "Public Transport", "Maintenance", "Parking"],
},
{
id: "groceries",
name: "Groceries",
type: "EXPENSE",
color: "#84cc16", // lime-500
icon: "Shopping",
},
{
id: "utilities",
name: "Utilities",
type: "EXPENSE",
color: "#06b6d4", // cyan-500
icon: "Zap",
subcategories: ["Electricity", "Water", "Gas", "Internet", "Phone"],
},
{
id: "entertainment",
name: "Entertainment",
type: "EXPENSE",
color: "#8b5cf6", // violet-500
icon: "Film",
subcategories: ["Movies", "Games", "Streaming Services"],
},
{
id: "food",
name: "Food",
type: "EXPENSE",
color: "#f43f5e", // rose-500
icon: "UtensilsCrossed",
},
{
id: "shopping",
name: "Shopping",
type: "EXPENSE",
color: "#ec4899", // pink-500
icon: "ShoppingBag",
subcategories: ["Clothing", "Electronics", "Home Goods"],
},
{
id: "healthcare",
name: "Healthcare",
type: "EXPENSE",
color: "#14b8a6", // teal-500
icon: "HeartPulse",
subcategories: ["Medical", "Dental", "Pharmacy", "Insurance"],
},
{
id: "education",
name: "Education",
type: "EXPENSE",
color: "#6366f1", // indigo-500
icon: "GraduationCap",
subcategories: ["Tuition", "Books", "Courses"],
},
{
id: "personal",
name: "Personal Care",
type: "EXPENSE",
color: "#d946ef", // fuchsia-500
icon: "Smile",
subcategories: ["Haircut", "Gym", "Beauty"],
},
{
id: "travel",
name: "Travel",
type: "EXPENSE",
color: "#0ea5e9", // sky-500
icon: "Plane",
},
{
id: "insurance",
name: "Insurance",
type: "EXPENSE",
color: "#64748b", // slate-500
icon: "Shield",
subcategories: ["Life", "Home", "Vehicle"],
},
{
id: "gifts",
name: "Gifts & Donations",
type: "EXPENSE",
color: "#f472b6", // pink-400
icon: "Gift",
},
{
id: "bills",
name: "Bills & Fees",
type: "EXPENSE",
color: "#fb7185", // rose-400
icon: "Receipt",
subcategories: ["Bank Fees", "Late Fees", "Service Charges"],
},
{
id: "other-expense",
name: "Other Expenses",
type: "EXPENSE",
color: "#94a3b8", // slate-400
icon: "MoreHorizontal",
},
];
export const categoryColors = defaultCategories.reduce((acc, category) => {
acc[category.id] = category.color;
return acc;
}, {});
================================================
FILE: data/landing.js
================================================
import {
BarChart3,
Receipt,
PieChart,
CreditCard,
Globe,
Zap,
} from "lucide-react";
// Stats Data
export const statsData = [
{
value: "50K+",
label: "Active Users",
},
{
value: "$2B+",
label: "Transactions Tracked",
},
{
value: "99.9%",
label: "Uptime",
},
{
value: "4.9/5",
label: "User Rating",
},
];
// Features Data
export const featuresData = [
{
icon: <BarChart3 className="h-8 w-8 text-blue-600" />,
title: "Advanced Analytics",
description:
"Get detailed insights into your spending patterns with AI-powered analytics",
},
{
icon: <Receipt className="h-8 w-8 text-blue-600" />,
title: "Smart Receipt Scanner",
description:
"Extract data automatically from receipts using advanced AI technology",
},
{
icon: <PieChart className="h-8 w-8 text-blue-600" />,
title: "Budget Planning",
description: "Create and manage budgets with intelligent recommendations",
},
{
icon: <CreditCard className="h-8 w-8 text-blue-600" />,
title: "Multi-Account Support",
description: "Manage multiple accounts and credit cards in one place",
},
{
icon: <Globe className="h-8 w-8 text-blue-600" />,
title: "Multi-Currency",
description: "Support for multiple currencies with real-time conversion",
},
{
icon: <Zap className="h-8 w-8 text-blue-600" />,
title: "Automated Insights",
description: "Get automated financial insights and recommendations",
},
];
// How It Works Data
export const howItWorksData = [
{
icon: <CreditCard className="h-8 w-8 text-blue-600" />,
title: "1. Create Your Account",
description:
"Get started in minutes with our simple and secure sign-up process",
},
{
icon: <BarChart3 className="h-8 w-8 text-blue-600" />,
title: "2. Track Your Spending",
description:
"Automatically categorize and track your transactions in real-time",
},
{
icon: <PieChart className="h-8 w-8 text-blue-600" />,
title: "3. Get Insights",
description:
"Receive AI-powered insights and recommendations to optimize your finances",
},
];
// Testimonials Data
export const testimonialsData = [
{
name: "Sarah Johnson",
role: "Small Business Owner",
image: "https://randomuser.me/api/portraits/women/75.jpg",
quote:
"Welth has transformed how I manage my business finances. The AI insights have helped me identify cost-saving opportunities I never knew existed.",
},
{
name: "Michael Chen",
role: "Freelancer",
image: "https://randomuser.me/api/portraits/men/75.jpg",
quote:
"The receipt scanning feature saves me hours each month. Now I can focus on my work instead of manual data entry and expense tracking.",
},
{
name: "Emily Rodriguez",
role: "Financial Advisor",
image: "https://randomuser.me/api/portraits/women/74.jpg",
quote:
"I recommend Welth to all my clients. The multi-currency support and detailed analytics make it perfect for international investors.",
},
];
================================================
FILE: emails/template.jsx
================================================
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
// Dummy data for preview
const PREVIEW_DATA = {
monthlyReport: {
userName: "John Doe",
type: "monthly-report",
data: {
month: "December",
stats: {
totalIncome: 5000,
totalExpenses: 3500,
byCategory: {
housing: 1500,
groceries: 600,
transportation: 400,
entertainment: 300,
utilities: 700,
},
},
insights: [
"Your housing expenses are 43% of your total spending - consider reviewing your housing costs.",
"Great job keeping entertainment expenses under control this month!",
"Setting up automatic savings could help you save 20% more of your income.",
],
},
},
budgetAlert: {
userName: "John Doe",
type: "budget-alert",
data: {
percentageUsed: 85,
budgetAmount: 4000,
totalExpenses: 3400,
},
},
};
export default function EmailTemplate({
userName = "",
type = "monthly-report",
data = {},
}) {
if (type === "monthly-report") {
return (
<Html>
<Head />
<Preview>Your Monthly Financial Report</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Heading style={styles.title}>Monthly Financial Report</Heading>
<Text style={styles.text}>Hello {userName},</Text>
<Text style={styles.text}>
Here’s your financial summary for {data?.month}:
</Text>
{/* Main Stats */}
<Section style={styles.statsContainer}>
<div style={styles.stat}>
<Text style={styles.text}>Total Income</Text>
<Text style={styles.heading}>${data?.stats.totalIncome}</Text>
</div>
<div style={styles.stat}>
<Text style={styles.text}>Total Expenses</Text>
<Text style={styles.heading}>${data?.stats.totalExpenses}</Text>
</div>
<div style={styles.stat}>
<Text style={styles.text}>Net</Text>
<Text style={styles.heading}>
${data?.stats.totalIncome - data?.stats.totalExpenses}
</Text>
</div>
</Section>
{/* Category Breakdown */}
{data?.stats?.byCategory && (
<Section style={styles.section}>
<Heading style={styles.heading}>Expenses by Category</Heading>
{Object.entries(data?.stats.byCategory).map(
([category, amount]) => (
<div key={category} style={styles.row}>
<Text style={styles.text}>{category}</Text>
<Text style={styles.text}>${amount}</Text>
</div>
)
)}
</Section>
)}
{/* AI Insights */}
{data?.insights && (
<Section style={styles.section}>
<Heading style={styles.heading}>Welth Insights</Heading>
{data.insights.map((insight, index) => (
<Text key={index} style={styles.text}>
• {insight}
</Text>
))}
</Section>
)}
<Text style={styles.footer}>
Thank you for using Welth. Keep tracking your finances for better
financial health!
</Text>
</Container>
</Body>
</Html>
);
}
if (type === "budget-alert") {
return (
<Html>
<Head />
<Preview>Budget Alert</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Heading style={styles.title}>Budget Alert</Heading>
<Text style={styles.text}>Hello {userName},</Text>
<Text style={styles.text}>
You’ve used {data?.percentageUsed.toFixed(1)}% of your
monthly budget.
</Text>
<Section style={styles.statsContainer}>
<div style={styles.stat}>
<Text style={styles.text}>Budget Amount</Text>
<Text style={styles.heading}>${data?.budgetAmount}</Text>
</div>
<div style={styles.stat}>
<Text style={styles.text}>Spent So Far</Text>
<Text style={styles.heading}>${data?.totalExpenses}</Text>
</div>
<div style={styles.stat}>
<Text style={styles.text}>Remaining</Text>
<Text style={styles.heading}>
${data?.budgetAmount - data?.totalExpenses}
</Text>
</div>
</Section>
</Container>
</Body>
</Html>
);
}
}
const styles = {
body: {
backgroundColor: "#f6f9fc",
fontFamily: "-apple-system, sans-serif",
},
container: {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px",
borderRadius: "5px",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
},
title: {
color: "#1f2937",
fontSize: "32px",
fontWeight: "bold",
textAlign: "center",
margin: "0 0 20px",
},
heading: {
color: "#1f2937",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 16px",
},
text: {
color: "#4b5563",
fontSize: "16px",
margin: "0 0 16px",
},
section: {
marginTop: "32px",
padding: "20px",
backgroundColor: "#f9fafb",
borderRadius: "5px",
border: "1px solid #e5e7eb",
},
statsContainer: {
margin: "32px 0",
padding: "20px",
backgroundColor: "#f9fafb",
borderRadius: "5px",
},
stat: {
marginBottom: "16px",
padding: "12px",
backgroundColor: "#fff",
borderRadius: "4px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
},
row: {
display: "flex",
justifyContent: "space-between",
padding: "12px 0",
borderBottom: "1px solid #e5e7eb",
},
footer: {
color: "#6b7280",
fontSize: "14px",
textAlign: "center",
marginTop: "32px",
paddingTop: "16px",
borderTop: "1px solid #e5e7eb",
},
};
================================================
FILE: hooks/use-fetch.js
================================================
import { useState } from "react";
import { toast } from "sonner";
const useFetch = (cb) => {
const [data, setData] = useState(undefined);
const [loading, setLoading] = useState(null);
const [error, setError] = useState(null);
const fn = async (...args) => {
setLoading(true);
setError(null);
try {
const response = await cb(...args);
setData(response);
setError(null);
} catch (error) {
setError(error);
toast.error(error.message);
} finally {
setLoading(false);
}
};
return { data, loading, error, fn, setData };
};
export default useFetch;
================================================
FILE: jsconfig.json
================================================
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}
================================================
FILE: lib/arcjet.js
================================================
import arcjet, { tokenBucket } from "@arcjet/next";
const aj = arcjet({
key: process.env.ARCJET_KEY,
characteristics: ["userId"], // Track based on Clerk userId
rules: [
// Rate limiting specifically for collection creation
tokenBucket({
mode: "LIVE",
refillRate: 10, // 10 collections
interval: 3600, // per hour
capacity: 10, // maximum burst capacity
}),
],
});
export default aj;
================================================
FILE: lib/checkUser.js
================================================
import { currentUser } from "@clerk/nextjs/server";
import { db } from "./prisma";
export const checkUser = async () => {
const user = await currentUser();
if (!user) {
return null;
}
try {
const loggedInUser = await db.user.findUnique({
where: {
clerkUserId: user.id,
},
});
if (loggedInUser) {
return loggedInUser;
}
const name = `${user.firstName} ${user.lastName}`;
const newUser = await db.user.create({
data: {
clerkUserId: user.id,
name,
imageUrl: user.imageUrl,
email: user.emailAddresses[0].emailAddress,
},
});
return newUser;
} catch (error) {
console.log(error.message);
}
};
================================================
FILE: lib/inngest/client.js
================================================
import { Inngest } from "inngest";
export const inngest = new Inngest({
id: "finance-platform", // Unique app ID
name: "Finance Platform",
retryFunction: async (attempt) => ({
delay: Math.pow(2, attempt) * 1000, // Exponential backoff
maxAttempts: 2,
}),
});
================================================
FILE: lib/inngest/function.js
================================================
import { inngest } from "./client";
import { db } from "@/lib/prisma";
import EmailTemplate from "@/emails/template";
import { sendEmail } from "@/actions/send-email";
import { GoogleGenerativeAI } from "@google/generative-ai";
// 1. Recurring Transaction Processing with Throttling
export const processRecurringTransaction = inngest.createFunction(
{
id: "process-recurring-transaction",
name: "Process Recurring Transaction",
throttle: {
limit: 10, // Process 10 transactions
period: "1m", // per minute
key: "event.data.userId", // Throttle per user
},
},
{ event: "transaction.recurring.process" },
async ({ event, step }) => {
// Validate event data
if (!event?.data?.transactionId || !event?.data?.userId) {
console.error("Invalid event data:", event);
return { error: "Missing required event data" };
}
await step.run("process-transaction", async () => {
const transaction = await db.transaction.findUnique({
where: {
id: event.data.transactionId,
userId: event.data.userId,
},
include: {
account: true,
},
});
if (!transaction || !isTransactionDue(transaction)) return;
// Create new transaction and update account balance in a transaction
await db.$transaction(async (tx) => {
// Create new transaction
await tx.transaction.create({
data: {
type: transaction.type,
amount: transaction.amount,
description: `${transaction.description} (Recurring)`,
date: new Date(),
category: transaction.category,
userId: transaction.userId,
accountId: transaction.accountId,
isRecurring: false,
},
});
// Update account balance
const balanceChange =
transaction.type === "EXPENSE"
? -transaction.amount.toNumber()
: transaction.amount.toNumber();
await tx.account.update({
where: { id: transaction.accountId },
data: { balance: { increment: balanceChange } },
});
// Update last processed date and next recurring date
await tx.transaction.update({
where: { id: transaction.id },
data: {
lastProcessed: new Date(),
nextRecurringDate: calculateNextRecurringDate(
new Date(),
transaction.recurringInterval
),
},
});
});
});
}
);
// Trigger recurring transactions with batching
export const triggerRecurringTransactions = inngest.createFunction(
{
id: "trigger-recurring-transactions", // Unique ID,
name: "Trigger Recurring Transactions",
},
{ cron: "0 0 * * *" }, // Daily at midnight
async ({ step }) => {
const recurringTransactions = await step.run(
"fetch-recurring-transactions",
async () => {
return await db.transaction.findMany({
where: {
isRecurring: true,
status: "COMPLETED",
OR: [
{ lastProcessed: null },
{
nextRecurringDate: {
lte: new Date(),
},
},
],
},
});
}
);
// Send event for each recurring transaction in batches
if (recurringTransactions.length > 0) {
const events = recurringTransactions.map((transaction) => ({
name: "transaction.recurring.process",
data: {
transactionId: transaction.id,
userId: transaction.userId,
},
}));
// Send events directly using inngest.send()
await inngest.send(events);
}
return { triggered: recurringTransactions.length };
}
);
// 2. Monthly Report Generation
async function generateFinancialInsights(stats, month) {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
const prompt = `
Analyze this financial data and provide 3 concise, actionable insights.
Focus on spending patterns and practical advice.
Keep it friendly and conversational.
Financial Data for ${month}:
- Total Income: $${stats.totalIncome}
- Total Expenses: $${stats.totalExpenses}
- Net Income: $${stats.totalIncome - stats.totalExpenses}
- Expense Categories: ${Object.entries(stats.byCategory)
.map(([category, amount]) => `${category}: $${amount}`)
.join(", ")}
Format the response as a JSON array of strings, like this:
["insight 1", "insight 2", "insight 3"]
`;
try {
const result = await model.generateContent(prompt);
const response = result.response;
const text = response.text();
const cleanedText = text.replace(/```(?:json)?\n?/g, "").trim();
return JSON.parse(cleanedText);
} catch (error) {
console.error("Error generating insights:", error);
return [
"Your highest expense category this month might need attention.",
"Consider setting up a budget for better financial management.",
"Track your recurring expenses to identify potential savings.",
];
}
}
export const generateMonthlyReports = inngest.createFunction(
{
id: "generate-monthly-reports",
name: "Generate Monthly Reports",
},
{ cron: "0 0 1 * *" }, // First day of each month
async ({ step }) => {
const users = await step.run("fetch-users", async () => {
return await db.user.findMany({
include: { accounts: true },
});
});
for (const user of users) {
await step.run(`generate-report-${user.id}`, async () => {
const lastMonth = new Date();
lastMonth.setMonth(lastMonth.getMonth() - 1);
const stats = await getMonthlyStats(user.id, lastMonth);
const monthName = lastMonth.toLocaleString("default", {
month: "long",
});
// Generate AI insights
const insights = await generateFinancialInsights(stats, monthName);
await sendEmail({
to: user.email,
subject: `Your Monthly Financial Report - ${monthName}`,
react: EmailTemplate({
userName: user.name,
type: "monthly-report",
data: {
stats,
month: monthName,
insights,
},
}),
});
});
}
return { processed: users.length };
}
);
// 3. Budget Alerts with Event Batching
export const checkBudgetAlerts = inngest.createFunction(
{ name: "Check Budget Alerts" },
{ cron: "0 */6 * * *" }, // Every 6 hours
async ({ step }) => {
const budgets = await step.run("fetch-budgets", async () => {
return await db.budget.findMany({
include: {
user: {
include: {
accounts: {
where: {
isDefault: true,
},
},
},
},
},
});
});
for (const budget of budgets) {
const defaultAccount = budget.user.accounts[0];
if (!defaultAccount) continue; // Skip if no default account
await step.run(`check-budget-${budget.id}`, async () => {
const startDate = new Date();
startDate.setDate(1); // Start of current month
// Calculate total expenses for the default account only
const expenses = await db.transaction.aggregate({
where: {
userId: budget.userId,
accountId: defaultAccount.id, // Only consider default account
type: "EXPENSE",
date: {
gte: startDate,
},
},
_sum: {
amount: true,
},
});
const totalExpenses = expenses._sum.amount?.toNumber() || 0;
const budgetAmount = budget.amount;
const percentageUsed = (totalExpenses / budgetAmount) * 100;
// Check if we should send an alert
if (
percentageUsed >= 80 && // Default threshold of 80%
(!budget.lastAlertSent ||
isNewMonth(new Date(budget.lastAlertSent), new Date()))
) {
await sendEmail({
to: budget.user.email,
subject: `Budget Alert for ${defaultAccount.name}`,
react: EmailTemplate({
userName: budget.user.name,
type: "budget-alert",
data: {
percentageUsed,
budgetAmount: parseInt(budgetAmount).toFixed(1),
totalExpenses: parseInt(totalExpenses).toFixed(1),
accountName: defaultAccount.name,
},
}),
});
// Update last alert sent
await db.budget.update({
where: { id: budget.id },
data: { lastAlertSent: new Date() },
});
}
});
}
}
);
function isNewMonth(lastAlertDate, currentDate) {
return (
lastAlertDate.getMonth() !== currentDate.getMonth() ||
lastAlertDate.getFullYear() !== currentDate.getFullYear()
);
}
// Utility functions
function isTransactionDue(transaction) {
// If no lastProcessed date, transaction is due
if (!transaction.lastProcessed) return true;
const today = new Date();
const nextDue = new Date(transaction.nextRecurringDate);
// Compare with nextDue date
return nextDue <= today;
}
function calculateNextRecurringDate(date, interval) {
const next = new Date(date);
switch (interval) {
case "DAILY":
next.setDate(next.getDate() + 1);
break;
case "WEEKLY":
next.setDate(next.getDate() + 7);
break;
case "MONTHLY":
next.setMonth(next.getMonth() + 1);
break;
case "YEARLY":
next.setFullYear(next.getFullYear() + 1);
break;
}
return next;
}
async function getMonthlyStats(userId, month) {
const startDate = new Date(month.getFullYear(), month.getMonth(), 1);
const endDate = new Date(month.getFullYear(), month.getMonth() + 1, 0);
const transactions = await db.transaction.findMany({
where: {
userId,
date: {
gte: startDate,
lte: endDate,
},
},
});
return transactions.reduce(
(stats, t) => {
const amount = t.amount.toNumber();
if (t.type === "EXPENSE") {
stats.totalExpenses += amount;
stats.byCategory[t.category] =
(stats.byCategory[t.category] || 0) + amount;
} else {
stats.totalIncome += amount;
}
return stats;
},
{
totalExpenses: 0,
totalIncome: 0,
byCategory: {},
transactionCount: transactions.length,
}
);
}
================================================
FILE: lib/prisma.js
================================================
import { PrismaClient } from "@prisma/client";
export const db = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalThis.prisma = db;
}
// globalThis.prisma: This global variable ensures that the Prisma client instance is
// reused across hot reloads during development. Without this, each time your application
// reloads, a new instance of the Prisma client would be created, potentially leading
// to connection issues.
================================================
FILE: lib/utils.js
================================================
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
================================================
FILE: middleware.js
================================================
import arcjet, { createMiddleware, detectBot, shield } from "@arcjet/next";
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
const isProtectedRoute = createRouteMatcher([
"/dashboard(.*)",
"/account(.*)",
"/transaction(.*)",
]);
// Create Arcjet middleware
const aj = arcjet({
key: process.env.ARCJET_KEY,
// characteristics: ["userId"], // Track based on Clerk userId
rules: [
// Shield protection for content and security
shield({
mode: "LIVE",
}),
detectBot({
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only
allow: [
"CATEGORY:SEARCH_ENGINE", // Google, Bing, etc
"GO_HTTP", // For Inngest
// See the full list at https://arcjet.com/bot-list
],
}),
],
});
// Create base Clerk middleware
const clerk = clerkMiddleware(async (auth, req) => {
const { userId } = await auth();
if (!userId && isProtectedRoute(req)) {
const { redirectToSignIn } = await auth();
return redirectToSignIn();
}
return NextResponse.next();
});
// Chain middlewares - ArcJet runs first, then Clerk
export default createMiddleware(aj, clerk);
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
================================================
FILE: next.config.mjs
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "randomuser.me",
},
],
},
experimental: {
serverActions: {
bodySizeLimit: "5mb",
},
},
};
export default nextConfig;
================================================
FILE: package.json
================================================
{
"name": "finance-platform",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"postinstall": "prisma generate"
},
"dependencies": {
"@arcjet/next": "^1.0.0-alpha.34",
"@clerk/nextjs": "^6.6.0",
"@google/generative-ai": "^0.21.0",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.0.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@react-email/components": "0.0.30",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"inngest": "^3.27.4",
"lucide-react": "^0.462.0",
"next": "15.0.5",
"next-themes": "^0.4.3",
"react": "^19.0.0-rc-66855b96-20241106",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0-rc-66855b96-20241106",
"react-hook-form": "^7.53.2",
"react-spinners": "^0.14.1",
"recharts": "^2.14.1",
"resend": "^4.0.1",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1",
"zod": "^3.23.8"
},
"devDependencies": {
"eslint": "^8",
"eslint-config-next": "15.0.3",
"postcss": "^8",
"prisma": "^6.0.1",
"react-email": "3.0.3",
"tailwindcss": "^3.4.1"
}
}
================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
================================================
FILE: prisma/migrations/20241204141034_init/migration.sql
================================================
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('INCOME', 'EXPENSE');
-- CreateEnum
CREATE TYPE "AccountType" AS ENUM ('CURRENT', 'SAVINGS');
-- CreateEnum
CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "BudgetPeriod" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
-- CreateEnum
CREATE TYPE "RecurringInterval" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"clerkUserId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "AccountType" NOT NULL,
"balance" DECIMAL(65,30) NOT NULL DEFAULT 0,
"currency" TEXT NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"color" TEXT,
"icon" TEXT,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"currency" TEXT NOT NULL,
"description" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"receiptUrl" TEXT,
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
"recurringInterval" "RecurringInterval",
"nextRecurringDate" TIMESTAMP(3),
"status" "TransactionStatus" NOT NULL DEFAULT 'COMPLETED',
"notes" TEXT,
"userId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "budgets" (
"id" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"currency" TEXT NOT NULL,
"period" "BudgetPeriod" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"userId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "budgets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_clerkUserId_key" ON "users"("clerkUserId");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "accounts_userId_idx" ON "accounts"("userId");
-- CreateIndex
CREATE INDEX "categories_userId_idx" ON "categories"("userId");
-- CreateIndex
CREATE INDEX "transactions_userId_idx" ON "transactions"("userId");
-- CreateIndex
CREATE INDEX "transactions_categoryId_idx" ON "transactions"("categoryId");
-- CreateIndex
CREATE INDEX "transactions_accountId_idx" ON "transactions"("accountId");
-- CreateIndex
CREATE INDEX "budgets_userId_idx" ON "budgets"("userId");
-- CreateIndex
CREATE INDEX "budgets_categoryId_idx" ON "budgets"("categoryId");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: prisma/migrations/20241205074927_remove_currency/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `currency` on the `accounts` table. All the data in the column will be lost.
- You are about to drop the column `currency` on the `budgets` table. All the data in the column will be lost.
- You are about to drop the column `currency` on the `transactions` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "accounts" DROP COLUMN "currency";
-- AlterTable
ALTER TABLE "budgets" DROP COLUMN "currency";
-- AlterTable
ALTER TABLE "transactions" DROP COLUMN "currency";
================================================
FILE: prisma/migrations/20241205094020_remove_categories/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `categoryId` on the `budgets` table. All the data in the column will be lost.
- You are about to drop the column `categoryId` on the `transactions` table. All the data in the column will be lost.
- You are about to drop the `categories` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `category` to the `budgets` table without a default value. This is not possible if the table is not empty.
- Added the required column `category` to the `transactions` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "budgets" DROP CONSTRAINT "budgets_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "categories" DROP CONSTRAINT "categories_userId_fkey";
-- DropForeignKey
ALTER TABLE "transactions" DROP CONSTRAINT "transactions_categoryId_fkey";
-- DropIndex
DROP INDEX "budgets_categoryId_idx";
-- DropIndex
DROP INDEX "transactions_categoryId_idx";
-- AlterTable
ALTER TABLE "budgets" DROP COLUMN "categoryId",
ADD COLUMN "category" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "transactions" DROP COLUMN "categoryId",
ADD COLUMN "category" TEXT NOT NULL;
-- DropTable
DROP TABLE "categories";
================================================
FILE: prisma/migrations/20241205094352_remove_categories/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `category` on the `budgets` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "budgets" DROP COLUMN "category";
================================================
FILE: prisma/migrations/20241206121749_budget/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `endDate` on the `budgets` table. All the data in the column will be lost.
- You are about to drop the column `period` on the `budgets` table. All the data in the column will be lost.
- You are about to drop the column `startDate` on the `budgets` table. All the data in the column will be lost.
- A unique constraint covering the columns `[userId]` on the table `budgets` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "budgets_userId_idx";
-- AlterTable
ALTER TABLE "budgets" DROP COLUMN "endDate",
DROP COLUMN "period",
DROP COLUMN "startDate";
-- CreateIndex
CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId");
================================================
FILE: prisma/migrations/20241208092553_budget/migration.sql
================================================
-- DropIndex
DROP INDEX "budgets_userId_key";
-- AlterTable
ALTER TABLE "budgets" ADD COLUMN "lastAlertSent" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "lastProcessed" TIMESTAMP(3);
-- DropEnum
DROP TYPE "BudgetPeriod";
-- CreateIndex
CREATE INDEX "budgets_userId_idx" ON "budgets"("userId");
================================================
FILE: prisma/migrations/20241208122341_budget/migration.sql
================================================
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `budgets` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId");
================================================
FILE: prisma/migrations/20241209133842_remove/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `notes` on the `transactions` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "transactions" DROP COLUMN "notes";
================================================
FILE: prisma/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
================================================
FILE: prisma/schema.prisma
================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model User {
id String @id @default(uuid())
clerkUserId String @unique // clerk user id
email String @unique
name String?
imageUrl String?
transactions Transaction[]
accounts Account[]
budgets Budget[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Account {
id String @id @default(uuid())
name String
type AccountType
balance Decimal @default(0) // will ask inital balance while creating an account
isDefault Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("accounts")
}
model Transaction {
id String @id @default(uuid())
type TransactionType
amount Decimal
description String?
date DateTime
category String
receiptUrl String?
isRecurring Boolean @default(false)
recurringInterval RecurringInterval? // Only used if isRecurring is true
nextRecurringDate DateTime? // Next date for recurring transaction
lastProcessed DateTime? // Last time this recurring transaction was processed
status TransactionStatus @default(COMPLETED)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accountId String
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([accountId])
@@map("transactions")
}
model Budget {
id String @id @default(uuid())
amount Decimal
lastAlertSent DateTime? // Track when the last alert was sent
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("budgets")
}
enum TransactionType {
INCOME
EXPENSE
}
enum AccountType {
CURRENT
SAVINGS
}
enum TransactionStatus {
PENDING
COMPLETED
FAILED
}
enum RecurringInterval {
DAILY
WEEKLY
MONTHLY
YEARLY
}
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
gitextract_amwpott_/ ├── .eslintrc.json ├── .gitignore ├── README.md ├── actions/ │ ├── account.js │ ├── budget.js │ ├── dashboard.js │ ├── seed.js │ ├── send-email.js │ └── transaction.js ├── app/ │ ├── (auth)/ │ │ ├── layout.js │ │ ├── sign-in/ │ │ │ └── [[...sign-in]]/ │ │ │ └── page.jsx │ │ └── sign-up/ │ │ └── [[...sign-up]]/ │ │ └── page.jsx │ ├── (main)/ │ │ ├── account/ │ │ │ ├── [id]/ │ │ │ │ └── page.jsx │ │ │ └── _components/ │ │ │ ├── account-chart.jsx │ │ │ ├── no-pagination-transaction-table.jsx │ │ │ └── transaction-table.jsx │ │ ├── dashboard/ │ │ │ ├── _components/ │ │ │ │ ├── account-card.jsx │ │ │ │ ├── budget-progress.jsx │ │ │ │ └── transaction-overview.jsx │ │ │ ├── layout.js │ │ │ └── page.jsx │ │ ├── layout.js │ │ └── transaction/ │ │ ├── _components/ │ │ │ ├── recipt-scanner.jsx │ │ │ └── transaction-form.jsx │ │ └── create/ │ │ └── page.jsx │ ├── api/ │ │ ├── inngest/ │ │ │ └── route.js │ │ └── seed/ │ │ └── route.js │ ├── globals.css │ ├── layout.js │ ├── lib/ │ │ └── schema.js │ ├── not-found.jsx │ └── page.js ├── components/ │ ├── create-account-drawer.jsx │ ├── header.jsx │ ├── hero.jsx │ └── ui/ │ ├── badge.jsx │ ├── button.jsx │ ├── calendar.jsx │ ├── card.jsx │ ├── checkbox.jsx │ ├── drawer.jsx │ ├── dropdown-menu.jsx │ ├── input.jsx │ ├── popover.jsx │ ├── progress.jsx │ ├── select.jsx │ ├── sonner.jsx │ ├── switch.jsx │ ├── table.jsx │ └── tooltip.jsx ├── components.json ├── data/ │ ├── categories.js │ └── landing.js ├── emails/ │ └── template.jsx ├── hooks/ │ └── use-fetch.js ├── jsconfig.json ├── lib/ │ ├── arcjet.js │ ├── checkUser.js │ ├── inngest/ │ │ ├── client.js │ │ └── function.js │ ├── prisma.js │ └── utils.js ├── middleware.js ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── prisma/ │ ├── migrations/ │ │ ├── 20241204141034_init/ │ │ │ └── migration.sql │ │ ├── 20241205074927_remove_currency/ │ │ │ └── migration.sql │ │ ├── 20241205094020_remove_categories/ │ │ │ └── migration.sql │ │ ├── 20241205094352_remove_categories/ │ │ │ └── migration.sql │ │ ├── 20241206121749_budget/ │ │ │ └── migration.sql │ │ ├── 20241208092553_budget/ │ │ │ └── migration.sql │ │ ├── 20241208122341_budget/ │ │ │ └── migration.sql │ │ ├── 20241209133842_remove/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ └── schema.prisma └── tailwind.config.js
SYMBOL INDEX (71 symbols across 33 files)
FILE: actions/account.js
function getAccountWithTransactions (line 18) | async function getAccountWithTransactions(accountId) {
function bulkDeleteTransactions (line 51) | async function bulkDeleteTransactions(transactionIds) {
function updateDefaultAccount (line 114) | async function updateDefaultAccount(accountId) {
FILE: actions/budget.js
function getCurrentBudget (line 7) | async function getCurrentBudget(accountId) {
function updateBudget (line 66) | async function updateBudget(amount) {
FILE: actions/dashboard.js
function getUserAccounts (line 20) | async function getUserAccounts() {
function createAccount (line 54) | async function createAccount(data) {
function getDashboardData (line 137) | async function getDashboardData() {
FILE: actions/seed.js
constant ACCOUNT_ID (line 6) | const ACCOUNT_ID = "account-id";
constant USER_ID (line 7) | const USER_ID = "user-id";
constant CATEGORIES (line 10) | const CATEGORIES = {
function getRandomAmount (line 32) | function getRandomAmount(min, max) {
function getRandomCategory (line 37) | function getRandomCategory(type) {
function seedTransactions (line 44) | async function seedTransactions() {
FILE: actions/send-email.js
function sendEmail (line 5) | async function sendEmail({ to, subject, react }) {
FILE: actions/transaction.js
function createTransaction (line 18) | async function createTransaction(data) {
function getTransaction (line 102) | async function getTransaction(id) {
function updateTransaction (line 124) | async function updateTransaction(id, data) {
function getUserTransactions (line 198) | async function getUserTransactions(query = {}) {
function scanReceipt (line 231) | async function scanReceipt(file) {
function calculateNextRecurringDate (line 294) | function calculateNextRecurringDate(startDate, interval) {
FILE: app/(auth)/sign-in/[[...sign-in]]/page.jsx
function Page (line 3) | function Page() {
FILE: app/(auth)/sign-up/[[...sign-up]]/page.jsx
function Page (line 3) | function Page() {
FILE: app/(main)/account/[id]/page.jsx
function AccountPage (line 8) | async function AccountPage({ params }) {
FILE: app/(main)/account/_components/account-chart.jsx
constant DATE_RANGES (line 24) | const DATE_RANGES = {
function AccountChart (line 32) | function AccountChart({ transactions }) {
FILE: app/(main)/account/_components/no-pagination-transaction-table.jsx
constant RECURRING_INTERVALS (line 56) | const RECURRING_INTERVALS = {
function NoPaginationTransactionTable (line 63) | function NoPaginationTransactionTable({ transactions }) {
FILE: app/(main)/account/_components/transaction-table.jsx
constant ITEMS_PER_PAGE (line 58) | const ITEMS_PER_PAGE = 10;
constant RECURRING_INTERVALS (line 60) | const RECURRING_INTERVALS = {
function TransactionTable (line 67) | function TransactionTable({ transactions }) {
FILE: app/(main)/dashboard/_components/account-card.jsx
function AccountCard (line 19) | function AccountCard({ account }) {
FILE: app/(main)/dashboard/_components/budget-progress.jsx
function BudgetProgress (line 20) | function BudgetProgress({ initialBudget, currentExpenses }) {
FILE: app/(main)/dashboard/_components/transaction-overview.jsx
constant COLORS (line 25) | const COLORS = [
function DashboardOverview (line 35) | function DashboardOverview({ accounts, transactions }) {
FILE: app/(main)/dashboard/layout.js
function Layout (line 5) | function Layout() {
FILE: app/(main)/dashboard/page.jsx
function DashboardPage (line 12) | async function DashboardPage() {
FILE: app/(main)/transaction/_components/recipt-scanner.jsx
function ReceiptScanner (line 10) | function ReceiptScanner({ onScanComplete }) {
FILE: app/(main)/transaction/_components/transaction-form.jsx
function AddTransactionForm (line 34) | function AddTransactionForm({
FILE: app/(main)/transaction/create/page.jsx
function AddTransactionPage (line 6) | async function AddTransactionPage({ searchParams }) {
FILE: app/api/seed/route.js
function GET (line 3) | async function GET() {
FILE: app/layout.js
function RootLayout (line 14) | function RootLayout({ children }) {
FILE: app/not-found.jsx
function NotFound (line 4) | function NotFound() {
FILE: components/create-account-drawer.jsx
function CreateAccountDrawer (line 31) | function CreateAccountDrawer({ children }) {
FILE: components/ui/badge.jsx
function Badge (line 26) | function Badge({
FILE: components/ui/calendar.jsx
function Calendar (line 9) | function Calendar({
FILE: emails/template.jsx
constant PREVIEW_DATA (line 13) | const PREVIEW_DATA = {
function EmailTemplate (line 48) | function EmailTemplate({
FILE: lib/inngest/function.js
function generateFinancialInsights (line 129) | async function generateFinancialInsights(stats, month) {
function isNewMonth (line 293) | function isNewMonth(lastAlertDate, currentDate) {
function isTransactionDue (line 301) | function isTransactionDue(transaction) {
function calculateNextRecurringDate (line 312) | function calculateNextRecurringDate(date, interval) {
function getMonthlyStats (line 331) | async function getMonthlyStats(userId, month) {
FILE: lib/utils.js
function cn (line 4) | function cn(...inputs) {
FILE: prisma/migrations/20241204141034_init/migration.sql
type "users" (line 17) | CREATE TABLE "users" (
type "accounts" (line 30) | CREATE TABLE "accounts" (
type "categories" (line 45) | CREATE TABLE "categories" (
type "transactions" (line 59) | CREATE TABLE "transactions" (
type "budgets" (line 82) | CREATE TABLE "budgets" (
type "users" (line 98) | CREATE UNIQUE INDEX "users_clerkUserId_key" ON "users"("clerkUserId")
type "users" (line 101) | CREATE UNIQUE INDEX "users_email_key" ON "users"("email")
type "accounts" (line 104) | CREATE INDEX "accounts_userId_idx" ON "accounts"("userId")
type "categories" (line 107) | CREATE INDEX "categories_userId_idx" ON "categories"("userId")
type "transactions" (line 110) | CREATE INDEX "transactions_userId_idx" ON "transactions"("userId")
type "transactions" (line 113) | CREATE INDEX "transactions_categoryId_idx" ON "transactions"("categoryId")
type "transactions" (line 116) | CREATE INDEX "transactions_accountId_idx" ON "transactions"("accountId")
type "budgets" (line 119) | CREATE INDEX "budgets_userId_idx" ON "budgets"("userId")
type "budgets" (line 122) | CREATE INDEX "budgets_categoryId_idx" ON "budgets"("categoryId")
FILE: prisma/migrations/20241206121749_budget/migration.sql
type "budgets" (line 19) | CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId")
FILE: prisma/migrations/20241208092553_budget/migration.sql
type "budgets" (line 14) | CREATE INDEX "budgets_userId_idx" ON "budgets"("userId")
FILE: prisma/migrations/20241208122341_budget/migration.sql
type "budgets" (line 8) | CREATE UNIQUE INDEX "budgets_userId_key" ON "budgets"("userId")
Condensed preview — 77 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (195K chars).
[
{
"path": ".eslintrc.json",
"chars": 89,
"preview": "{\n \"extends\": \"next/core-web-vitals\",\n \"rules\": {\n \"no-unused-vars\": [\"warn\"]\n }\n}\n"
},
{
"path": ".gitignore",
"chars": 467,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "README.md",
"chars": 670,
"preview": "# Full Stack AI Fianace Platform with Next JS, Supabase, Tailwind, Prisma, Inngest, ArcJet, Shadcn UI Tutorial 🔥🔥\n## htt"
},
{
"path": "actions/account.js",
"chars": 3652,
"preview": "\"use server\";\n\nimport { db } from \"@/lib/prisma\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { revalidatePath }"
},
{
"path": "actions/budget.js",
"chars": 2219,
"preview": "\"use server\";\n\nimport { db } from \"@/lib/prisma\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { revalidatePath }"
},
{
"path": "actions/dashboard.js",
"chars": 3909,
"preview": "\"use server\";\n\nimport aj from \"@/lib/arcjet\";\nimport { db } from \"@/lib/prisma\";\nimport { request } from \"@arcjet/next\";"
},
{
"path": "actions/seed.js",
"chars": 3223,
"preview": "\"use server\";\n\nimport { db } from \"@/lib/prisma\";\nimport { subDays } from \"date-fns\";\n\nconst ACCOUNT_ID = \"account-id\";\n"
},
{
"path": "actions/send-email.js",
"chars": 472,
"preview": "\"use server\";\n\nimport { Resend } from \"resend\";\n\nexport async function sendEmail({ to, subject, react }) {\n const resen"
},
{
"path": "actions/transaction.js",
"chars": 8119,
"preview": "\"use server\";\n\nimport { auth } from \"@clerk/nextjs/server\";\nimport { db } from \"@/lib/prisma\";\nimport { revalidatePath }"
},
{
"path": "app/(auth)/layout.js",
"chars": 140,
"preview": "const AuthLayout = ({ children }) => {\n return <div className=\"flex justify-center pt-40\">{children}</div>;\n};\n\nexport "
},
{
"path": "app/(auth)/sign-in/[[...sign-in]]/page.jsx",
"chars": 97,
"preview": "import { SignIn } from \"@clerk/nextjs\";\n\nexport default function Page() {\n return <SignIn />;\n}\n"
},
{
"path": "app/(auth)/sign-up/[[...sign-up]]/page.jsx",
"chars": 97,
"preview": "import { SignUp } from \"@clerk/nextjs\";\n\nexport default function Page() {\n return <SignUp />;\n}\n"
},
{
"path": "app/(main)/account/[id]/page.jsx",
"chars": 1743,
"preview": "import { Suspense } from \"react\";\nimport { getAccountWithTransactions } from \"@/actions/account\";\nimport { BarLoader } f"
},
{
"path": "app/(main)/account/_components/account-chart.jsx",
"chars": 5233,
"preview": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport {\n BarChart,\n Bar,\n XAxis,\n YAxis,\n CartesianGrid,"
},
{
"path": "app/(main)/account/_components/no-pagination-transaction-table.jsx",
"chars": 13705,
"preview": "\"use client\";\n\nimport { useState, useEffect, useMemo } from \"react\";\nimport {\n ChevronDown,\n ChevronUp,\n MoreHorizont"
},
{
"path": "app/(main)/account/_components/transaction-table.jsx",
"chars": 15261,
"preview": "\"use client\";\n\nimport { useState, useEffect, useMemo } from \"react\";\nimport {\n ChevronDown,\n ChevronUp,\n MoreHorizont"
},
{
"path": "app/(main)/dashboard/_components/account-card.jsx",
"chars": 2522,
"preview": "\"use client\";\n\nimport { ArrowUpRight, ArrowDownRight, CreditCard } from \"lucide-react\";\nimport { Switch } from \"@/compon"
},
{
"path": "app/(main)/dashboard/_components/budget-progress.jsx",
"chars": 4187,
"preview": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { Pencil, Check, X } from \"lucide-react\";\nimport useF"
},
{
"path": "app/(main)/dashboard/_components/transaction-overview.jsx",
"chars": 6149,
"preview": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n PieChart,\n Pie,\n Cell,\n ResponsiveContainer,\n Tooltip,\n "
},
{
"path": "app/(main)/dashboard/layout.js",
"chars": 539,
"preview": "import DashboardPage from \"./page\";\nimport { BarLoader } from \"react-spinners\";\nimport { Suspense } from \"react\";\n\nexpor"
},
{
"path": "app/(main)/dashboard/page.jsx",
"chars": 1991,
"preview": "import { Suspense } from \"react\";\nimport { getUserAccounts } from \"@/actions/dashboard\";\nimport { getDashboardData } fro"
},
{
"path": "app/(main)/layout.js",
"chars": 166,
"preview": "import React from \"react\";\n\nconst MainLayout = ({ children }) => {\n return <div className=\"container mx-auto my-32\">{ch"
},
{
"path": "app/(main)/transaction/_components/recipt-scanner.jsx",
"chars": 1895,
"preview": "\"use client\";\n\nimport { useRef, useEffect } from \"react\";\nimport { Camera, Loader2 } from \"lucide-react\";\nimport { Butto"
},
{
"path": "app/(main)/transaction/_components/transaction-form.jsx",
"chars": 10585,
"preview": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResolver } from"
},
{
"path": "app/(main)/transaction/create/page.jsx",
"chars": 908,
"preview": "import { getUserAccounts } from \"@/actions/dashboard\";\nimport { defaultCategories } from \"@/data/categories\";\nimport { A"
},
{
"path": "app/api/inngest/route.js",
"chars": 443,
"preview": "import { serve } from \"inngest/next\";\n\nimport { inngest } from \"@/lib/inngest/client\";\nimport {\n checkBudgetAlerts,\n g"
},
{
"path": "app/api/seed/route.js",
"chars": 159,
"preview": "import { seedTransactions } from \"@/actions/seed\";\n\nexport async function GET() {\n const result = await seedTransaction"
},
{
"path": "app/globals.css",
"chars": 2557,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n font-family: Arial, Helvetica, sans-serif;\n}\n\nhtml "
},
{
"path": "app/layout.js",
"chars": 962,
"preview": "import { Inter } from \"next/font/google\";\nimport \"./globals.css\";\nimport Header from \"@/components/header\";\nimport { Cle"
},
{
"path": "app/lib/schema.js",
"chars": 1043,
"preview": "import { z } from \"zod\";\n\nexport const accountSchema = z.object({\n name: z.string().min(1, \"Name is required\"),\n type:"
},
{
"path": "app/not-found.jsx",
"chars": 599,
"preview": "import Link from \"next/link\";\nimport { Button } from \"@/components/ui/button\";\n\nexport default function NotFound() {\n r"
},
{
"path": "app/page.js",
"chars": 4687,
"preview": "import React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/compone"
},
{
"path": "components/create-account-drawer.jsx",
"chars": 5643,
"preview": "\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResol"
},
{
"path": "components/header.jsx",
"chars": 2457,
"preview": "import React from \"react\";\nimport { Button } from \"./ui/button\";\nimport { PenBox, LayoutDashboard } from \"lucide-react\";"
},
{
"path": "components/hero.jsx",
"chars": 2082,
"preview": "\"use client\";\n\nimport React, { useEffect, useRef } from \"react\";\nimport Image from \"next/image\";\nimport { Button } from "
},
{
"path": "components/ui/badge.jsx",
"chars": 990,
"preview": "import * as React from \"react\"\nimport { cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\"\n\nconst "
},
{
"path": "components/ui/button.jsx",
"chars": 1710,
"preview": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva } from \"class-variance-authori"
},
{
"path": "components/ui/calendar.jsx",
"chars": 2743,
"preview": "\"use client\";\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { DayPicker"
},
{
"path": "components/ui/card.jsx",
"chars": 1440,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef(({ className, ...props }"
},
{
"path": "components/ui/checkbox.jsx",
"chars": 894,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { Chec"
},
{
"path": "components/ui/drawer.jsx",
"chars": 2359,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@/lib"
},
{
"path": "components/ui/dropdown-menu.jsx",
"chars": 6171,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimpo"
},
{
"path": "components/ui/input.jsx",
"chars": 690,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef(({ className, type, ..."
},
{
"path": "components/ui/popover.jsx",
"chars": 1180,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } "
},
{
"path": "components/ui/progress.jsx",
"chars": 743,
"preview": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\";\n\nimport { "
},
{
"path": "components/ui/select.jsx",
"chars": 4670,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, C"
},
{
"path": "components/ui/sonner.jsx",
"chars": 799,
"preview": "\"use client\";\nimport { useTheme } from \"next-themes\"\nimport { Toaster as Sonner } from \"sonner\"\n\nconst Toaster = ({\n .."
},
{
"path": "components/ui/switch.jsx",
"chars": 1039,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\"\n\nimport { cn } f"
},
{
"path": "components/ui/table.jsx",
"chars": 2231,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef(({ className, ...props "
},
{
"path": "components/ui/tooltip.jsx",
"chars": 1091,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } "
},
{
"path": "components.json",
"chars": 445,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": true,\n \"tsx\": false,\n \"tailwind\": "
},
{
"path": "data/categories.js",
"chars": 3577,
"preview": "export const defaultCategories = [\n // Income Categories\n {\n id: \"salary\",\n name: \"Salary\",\n type: \"INCOME\",\n"
},
{
"path": "data/landing.js",
"chars": 3077,
"preview": "import {\n BarChart3,\n Receipt,\n PieChart,\n CreditCard,\n Globe,\n Zap,\n} from \"lucide-react\";\n\n// Stats Data\nexport "
},
{
"path": "emails/template.jsx",
"chars": 6270,
"preview": "import {\n Body,\n Container,\n Head,\n Heading,\n Html,\n Preview,\n Section,\n Text,\n} from \"@react-email/components\";"
},
{
"path": "hooks/use-fetch.js",
"chars": 619,
"preview": "import { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nconst useFetch = (cb) => {\n const [data, setData] = "
},
{
"path": "jsconfig.json",
"chars": 73,
"preview": "{\n \"compilerOptions\": {\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n }\n}\n"
},
{
"path": "lib/arcjet.js",
"chars": 430,
"preview": "import arcjet, { tokenBucket } from \"@arcjet/next\";\n\nconst aj = arcjet({\n key: process.env.ARCJET_KEY,\n characteristic"
},
{
"path": "lib/checkUser.js",
"chars": 717,
"preview": "import { currentUser } from \"@clerk/nextjs/server\";\nimport { db } from \"./prisma\";\n\nexport const checkUser = async () =>"
},
{
"path": "lib/inngest/client.js",
"chars": 276,
"preview": "import { Inngest } from \"inngest\";\n\nexport const inngest = new Inngest({\n id: \"finance-platform\", // Unique app ID\n na"
},
{
"path": "lib/inngest/function.js",
"chars": 10698,
"preview": "import { inngest } from \"./client\";\nimport { db } from \"@/lib/prisma\";\nimport EmailTemplate from \"@/emails/template\";\nim"
},
{
"path": "lib/prisma.js",
"chars": 469,
"preview": "import { PrismaClient } from \"@prisma/client\";\n\nexport const db = globalThis.prisma || new PrismaClient();\n\nif (process."
},
{
"path": "lib/utils.js",
"chars": 137,
"preview": "import { clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs) {\n return twMerge("
},
{
"path": "middleware.js",
"chars": 1516,
"preview": "import arcjet, { createMiddleware, detectBot, shield } from \"@arcjet/next\";\nimport { clerkMiddleware, createRouteMatcher"
},
{
"path": "next.config.mjs",
"chars": 298,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n images: {\n remotePatterns: [\n {\n protocol"
},
{
"path": "package.json",
"chars": 1654,
"preview": "{\n \"name\": \"finance-platform\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev --turbopack"
},
{
"path": "postcss.config.mjs",
"chars": 135,
"preview": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n plugins: {\n tailwindcss: {},\n },\n};\n\nexport d"
},
{
"path": "prisma/migrations/20241204141034_init/migration.sql",
"chars": 4667,
"preview": "-- CreateEnum\nCREATE TYPE \"TransactionType\" AS ENUM ('INCOME', 'EXPENSE');\n\n-- CreateEnum\nCREATE TYPE \"AccountType\" AS E"
},
{
"path": "prisma/migrations/20241205074927_remove_currency/migration.sql",
"chars": 553,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `currency` on the `accounts` table. All the data in the column will"
},
{
"path": "prisma/migrations/20241205094020_remove_categories/migration.sql",
"chars": 1272,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `categoryId` on the `budgets` table. All the data in the column wil"
},
{
"path": "prisma/migrations/20241205094352_remove_categories/migration.sql",
"chars": 193,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `category` on the `budgets` table. All the data in the column will "
},
{
"path": "prisma/migrations/20241206121749_budget/migration.sql",
"chars": 741,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `endDate` on the `budgets` table. All the data in the column will b"
},
{
"path": "prisma/migrations/20241208092553_budget/migration.sql",
"chars": 328,
"preview": "-- DropIndex\nDROP INDEX \"budgets_userId_key\";\n\n-- AlterTable\nALTER TABLE \"budgets\" ADD COLUMN \"lastAlertSent\" TIMEST"
},
{
"path": "prisma/migrations/20241208122341_budget/migration.sql",
"chars": 250,
"preview": "/*\n Warnings:\n\n - A unique constraint covering the columns `[userId]` on the table `budgets` will be added. If there a"
},
{
"path": "prisma/migrations/20241209133842_remove/migration.sql",
"chars": 197,
"preview": "/*\n Warnings:\n\n - You are about to drop the column `notes` on the `transactions` table. All the data in the column wil"
},
{
"path": "prisma/migrations/migration_lock.toml",
"chars": 126,
"preview": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postg"
},
{
"path": "prisma/schema.prisma",
"chars": 2731,
"preview": "generator client {\n provider = \"prisma-client-js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATA"
},
{
"path": "tailwind.config.js",
"chars": 1627,
"preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n darkMode: [\"class\"],\n content: [\n \"./pages/**/*"
}
]
About this extraction
This page contains the full source code of the piyush-eon/ai-finance-platform GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 77 files (176.3 KB), approximately 45.1k tokens, and a symbol index with 71 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.