diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9c027f7..8d415dd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,8 +53,29 @@ jobs:
run: npm run api:prisma:migrate:deploy
env:
DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test
-
+
- name: Run API tests
run: npm --prefix apps/api run test
env:
- DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test
\ No newline at end of file
+ DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test
+
+ docker-build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Docker image (web)
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: apps/web/Dockerfile
+ push: false
+ build-args: |
+ NEXT_PUBLIC_API_URL=http://localhost:8000/api
+ tags: fintrack-web:ci
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/apps/api/package.json b/apps/api/package.json
index 9fb65fe..e3a04df 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -7,7 +7,7 @@
"scripts": {
"build": "rimraf dist && npx tsc",
"start": "node dist/server.js",
- "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand",
+ "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand",
"test:watch": "jest --watch",
"predev": "npm run build",
"dev": "concurrently \"npx tsc -w\" \"nodemon --ext js,yml dist/server.js\"",
diff --git a/apps/api/src/modules/ai/controller.ts b/apps/api/src/modules/ai/controller.ts
index e181e30..fcba017 100644
--- a/apps/api/src/modules/ai/controller.ts
+++ b/apps/api/src/modules/ai/controller.ts
@@ -1,8 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../../middleware/errorHandler.js";
-import { prisma } from "../../prisma/client.js";
import {
getAiResponse,
+ getAIHistory as getAIHistoryFromService,
AiServiceError,
ensureAiAccessOrThrow,
getAiAccessStatus,
@@ -19,27 +19,8 @@ export async function getAIHistory(
if (!userId)
throw new AppError("Unauthorized: User not found in request", 401);
- const messages = await prisma.message.findMany({
- where: { userId },
- orderBy: { created_at: "asc" },
- });
-
- const paired = [];
- for (let i = 0; i < messages.length; i++) {
- const currentMsg = messages[i];
- if (currentMsg && currentMsg.role === "user") {
- const nextMsg = messages[i + 1];
- paired.push({
- id: currentMsg.id,
- prompt: currentMsg.content,
- result: nextMsg?.role === "assistant" ? nextMsg.content : "",
- created_at: currentMsg.created_at,
- });
- if (nextMsg?.role === "assistant") i++;
- }
- }
-
- return res.json(paired.reverse());
+ const history = await getAIHistoryFromService(userId);
+ return res.json(history);
} catch (err) {
next(err);
}
diff --git a/apps/api/src/modules/ai/service.ts b/apps/api/src/modules/ai/service.ts
index a329e32..d8ae2b4 100644
--- a/apps/api/src/modules/ai/service.ts
+++ b/apps/api/src/modules/ai/service.ts
@@ -157,6 +157,30 @@ export async function incrementAiAnalysisUsage(
});
}
+export async function getAIHistory(userId: string) {
+ const messages = await prisma.message.findMany({
+ where: { userId },
+ orderBy: { created_at: "asc" },
+ });
+
+ const paired = [];
+ for (let i = 0; i < messages.length; i++) {
+ const currentMsg = messages[i];
+ if (currentMsg && currentMsg.role === "user") {
+ const nextMsg = messages[i + 1];
+ paired.push({
+ id: currentMsg.id,
+ prompt: currentMsg.content,
+ result: nextMsg?.role === "assistant" ? nextMsg.content : "",
+ created_at: currentMsg.created_at,
+ });
+ if (nextMsg?.role === "assistant") i++;
+ }
+ }
+
+ return paired.reverse();
+}
+
async function callGroq(
apiKey: string,
modelToUse: string,
diff --git a/apps/api/src/modules/donation/service.ts b/apps/api/src/modules/donation/service.ts
index 80a0e17..ffc12c6 100644
--- a/apps/api/src/modules/donation/service.ts
+++ b/apps/api/src/modules/donation/service.ts
@@ -1,5 +1,6 @@
import Stripe from "stripe";
import { Prisma } from "@prisma/client";
+import type { DonationLeaderboardItem } from "@fintrack/types";
import { ENV } from "../../config/env.js";
import { prisma } from "../../prisma/client.js";
import { AppError } from "../../middleware/errorHandler.js";
@@ -180,14 +181,6 @@ export async function createDonationCheckoutSession(
};
}
-export interface DonationLeaderboardItem {
- userId: string;
- name: string;
- photoUrl: string | null;
- totalAmountMinor: number;
- currency: string;
-}
-
export async function getDonationLeaderboard(
limit = 20,
): Promise {
diff --git a/apps/api/src/modules/transaction/controller.ts b/apps/api/src/modules/transaction/controller.ts
index 93623ce..87cbcd7 100644
--- a/apps/api/src/modules/transaction/controller.ts
+++ b/apps/api/src/modules/transaction/controller.ts
@@ -1,535 +1,14 @@
-import type { Request, Response, NextFunction } from "express";
-import { Prisma, TransactionSource, TransactionType } from "@prisma/client";
-import z from "zod";
-import * as transactionService from "./service.js";
-import { AppError } from "../../middleware/errorHandler.js";
-import {
- createTransactionSchema,
- manualCurrencyCodeSchema,
- monobankFetchPayloadSchema,
- monobankImportPayloadSchema,
- monobankTokenSchema,
- type MonobankAccount,
- type MonobankClientInfoResponse,
- type MonobankStatementItem,
- updateTransactionSchema,
-} from "@fintrack/types";
-
-const MONOBANK_API_BASE_URL = "https://api.monobank.ua";
-const MONOBANK_FETCH_COOLDOWN_SECONDS = 60;
-const MONOBANK_MAX_STATEMENT_RANGE_SECONDS = 2_682_000;
-const monobankClientInfoByUser = new Map();
-const monobankStatementByUser = new Map();
-
-const sourceQuerySchema = z
- .enum([TransactionSource.MANUAL, TransactionSource.MONOBANK])
- .optional();
-
-function normalizeCurrencyCode(input?: string) {
- if (!input) return "USD";
- const normalized = input.trim().toUpperCase();
- const parsed = manualCurrencyCodeSchema.safeParse(normalized);
- return parsed.success ? parsed.data : "USD";
-}
-
-function numericCurrencyToCode(code?: number) {
- if (code === 980) return "UAH";
- if (code === 840) return "USD";
- if (code === 978) return "EUR";
- return "USD";
-}
-
-function getSourceFilter(rawSource: unknown): TransactionSource | undefined {
- const parsed = sourceQuerySchema.safeParse(rawSource);
- if (!parsed.success) {
- throw new AppError("Invalid source. Allowed values: MANUAL, MONOBANK", 400);
- }
-
- return parsed.data;
-}
-
-function resolveStatementRange(from?: number, to?: number) {
- const nowUnix = Math.floor(Date.now() / 1000);
- const resolvedTo = to ?? nowUnix;
- const resolvedFrom =
- from ?? resolvedTo - (MONOBANK_MAX_STATEMENT_RANGE_SECONDS - 3600);
-
- if (resolvedFrom >= resolvedTo) {
- throw new AppError(
- "Invalid time range: 'from' must be less than 'to'",
- 400,
- );
- }
-
- if (resolvedTo - resolvedFrom > MONOBANK_MAX_STATEMENT_RANGE_SECONDS) {
- throw new AppError(
- `Invalid time range: max allowed interval is ${MONOBANK_MAX_STATEMENT_RANGE_SECONDS} seconds`,
- 400,
- );
- }
-
- return { from: resolvedFrom, to: resolvedTo };
-}
-
-function enforceMonobankCooldown(
- userId: string,
- operation: "client-info" | "statement",
-) {
- const map =
- operation === "client-info"
- ? monobankClientInfoByUser
- : monobankStatementByUser;
- const nowMs = Date.now();
- const previousRequestMs = map.get(userId);
-
- if (previousRequestMs) {
- const diffMs = nowMs - previousRequestMs;
- const cooldownMs = MONOBANK_FETCH_COOLDOWN_SECONDS * 1000;
- if (diffMs < cooldownMs) {
- const retryAfter = Math.ceil((cooldownMs - diffMs) / 1000);
- throw new AppError(
- `Monobank request cooldown is ${MONOBANK_FETCH_COOLDOWN_SECONDS}s. Retry after ${retryAfter}s.`,
- 429,
- { retryAfter },
- );
- }
- }
-
- map.set(userId, nowMs);
-}
-
-async function fetchMonobankJson(url: string, token: string): Promise {
- const response = await fetch(url, {
- method: "GET",
- headers: {
- "X-Token": token,
- Accept: "application/json",
- },
- });
-
- if (!response.ok) {
- let errorPayload: unknown = null;
- try {
- errorPayload = await response.json();
- } catch {
- errorPayload = await response.text();
- }
-
- if (response.status === 429) {
- throw new AppError(
- "Monobank rate limit exceeded. Retry in 60 seconds.",
- 429,
- {
- monobank: errorPayload,
- },
- );
- }
-
- if (response.status === 401 || response.status === 403) {
- throw new AppError("Invalid Monobank token", 401, {
- monobank: errorPayload,
- });
- }
-
- throw new AppError(
- "Failed to fetch data from Monobank API",
- response.status,
- {
- monobank: errorPayload,
- },
- );
- }
-
- return (await response.json()) as T;
-}
-
-function mapMonobankStatementToPreview(
- sourceAccountId: string,
- fallbackCurrencyCodeNumeric: number | undefined,
- tx: MonobankStatementItem,
-) {
- const rawAmount = Number(tx.amount);
- const normalizedAmount = Math.abs(rawAmount) / 100;
- const type =
- rawAmount >= 0 ? TransactionType.INCOME : TransactionType.EXPENSE;
-
- return {
- title:
- tx.description?.trim() || tx.comment?.trim() || "Monobank transaction",
- type,
- amount: normalizedAmount,
- currencyCode: numericCurrencyToCode(
- tx.currencyCode ?? fallbackCurrencyCodeNumeric,
- ),
- created_at: new Date(tx.time * 1000),
- sourceTransactionId: tx.id,
- sourceAccountId,
- source: TransactionSource.MONOBANK,
- };
-}
-
-export async function getAllTransactions(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
- const source = getSourceFilter(req.query.source);
-
- const hasPage = req.query.page !== undefined;
- const hasPerPage = req.query.perPage !== undefined;
-
- if (hasPage !== hasPerPage) {
- throw new AppError(
- "Both 'page' and 'perPage' are required for pagination",
- 400,
- );
- }
-
- if (hasPage && hasPerPage) {
- const page = Number(req.query.page);
- const perPage = Number(req.query.perPage);
- if (
- Number.isNaN(page) ||
- Number.isNaN(perPage) ||
- page < 1 ||
- perPage < 1
- ) {
- throw new AppError("Invalid pagination params", 400);
- }
- const transactions = await transactionService.getTransactionsPerPage(
- userId,
- page,
- perPage,
- source,
- );
- res.status(200).json(transactions);
- return;
- }
-
- const transactions = await transactionService.getAllTransactions(
- userId,
- source,
- );
- res.status(200).json(transactions);
- } catch (err) {
- next(err);
- }
-}
-
-export async function getTransaction(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- let transaction: object | null = {};
- const { id } = req.params;
- if (!id) throw new AppError("Transaction id is required", 400);
- if (Array.isArray(id)) {
- throw new AppError("Invalid id", 400);
- }
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
- transaction = await transactionService.getTransaction(id, userId);
- if (!transaction) throw new AppError("Not found", 404);
- res.status(200).json(transaction);
- } catch (err) {
- next(err);
- }
-}
-
-export async function createTransaction(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const validatedBody = createTransactionSchema.parse(req.body);
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const prismaData: Prisma.TransactionCreateInput = {
- title: validatedBody.title,
- type: validatedBody.type,
- amount: validatedBody.amount,
- currencyCode: normalizeCurrencyCode(validatedBody.currencyCode),
- ...(validatedBody.created_at
- ? { created_at: validatedBody.created_at }
- : {}),
- ...(validatedBody.updated_at
- ? { updated_at: validatedBody.updated_at }
- : {}),
- user: {
- connect: { id: userId },
- },
- ...(validatedBody.location && {
- location: {
- create: {
- latitude: validatedBody.location.latitude,
- longitude: validatedBody.location.longitude,
- },
- },
- }),
- };
- const transaction = await transactionService.createTransaction(prismaData);
- res.status(201).json(transaction);
- } catch (err) {
- next(err);
- }
-}
-
-export async function updateTransaction(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const { id } = req.params;
- if (!id) throw new AppError("Transaction id is required", 400);
- if (Array.isArray(id)) {
- throw new AppError("Invalid id", 400);
- }
- const validatedBody = updateTransactionSchema.parse(req.body);
-
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const transactionOfCurrentUser = await transactionService.getTransaction(
- id,
- userId,
- );
- if (!transactionOfCurrentUser) throw new AppError("Not found", 404);
- if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) {
- throw new AppError("Monobank transactions are read-only", 403);
- }
-
- const prismaData: Prisma.TransactionUpdateInput = {};
- if (validatedBody.title !== undefined)
- prismaData.title = validatedBody.title;
- if (validatedBody.type !== undefined) prismaData.type = validatedBody.type;
- if (validatedBody.amount !== undefined)
- prismaData.amount = validatedBody.amount;
- if (validatedBody.currencyCode !== undefined)
- prismaData.currencyCode = normalizeCurrencyCode(
- validatedBody.currencyCode,
- );
- if (validatedBody.created_at !== undefined)
- prismaData.created_at = validatedBody.created_at;
- if (validatedBody.updated_at !== undefined)
- prismaData.updated_at = validatedBody.updated_at;
- if (validatedBody.location) {
- prismaData.location = {
- upsert: {
- create: {
- latitude: validatedBody.location.latitude,
- longitude: validatedBody.location.longitude,
- },
- update: {
- latitude: validatedBody.location.latitude,
- longitude: validatedBody.location.longitude,
- },
- },
- };
- }
- if (validatedBody.location === null) {
- prismaData.location = { delete: true };
- }
-
- const transaction = await transactionService.updateTransaction(
- id,
- prismaData,
- );
- res.status(200).json(transaction);
- } catch (err) {
- next(err);
- }
-}
-
-export async function deleteTransaction(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const { id } = req.params;
- if (!id) throw new AppError("Transaction id is required", 400);
- if (Array.isArray(id)) {
- throw new AppError("Invalid id", 400);
- }
-
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const transactionOfCurrentUser = await transactionService.getTransaction(
- id,
- userId,
- );
- if (!transactionOfCurrentUser) throw new AppError("Not found", 404);
- if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) {
- throw new AppError("Monobank transactions are read-only", 403);
- }
-
- await transactionService.deleteTransaction(id);
- res.sendStatus(204);
- } catch (err) {
- next(err);
- }
-}
-
-export async function fetchMonobankTransactions(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const payload = monobankFetchPayloadSchema.parse(req.body);
- const { from, to } = resolveStatementRange(payload.from, payload.to);
-
- enforceMonobankCooldown(userId, "statement");
-
- let selectedAccountId = payload.accountId;
- let selectedAccount: MonobankAccount | null = null;
-
- if (!selectedAccountId) {
- enforceMonobankCooldown(userId, "client-info");
- const clientInfo = await fetchMonobankJson(
- `${MONOBANK_API_BASE_URL}/personal/client-info`,
- payload.token,
- );
- const accounts = clientInfo.accounts ?? [];
- if (accounts.length === 0) {
- throw new AppError(
- "No Monobank accounts available for this token",
- 404,
- );
- }
- selectedAccount = accounts[0] ?? null;
- selectedAccountId = selectedAccount?.id;
- }
-
- if (!selectedAccountId) {
- throw new AppError("Monobank account id is required", 400);
- }
-
- const statement = await fetchMonobankJson(
- `${MONOBANK_API_BASE_URL}/personal/statement/${selectedAccountId}/${from}/${to}`,
- payload.token,
- );
-
- const transactions = statement.map((tx) =>
- mapMonobankStatementToPreview(
- selectedAccountId,
- payload.accountCurrencyCode,
- tx,
- ),
- );
-
- res.status(200).json({
- account: selectedAccount ?? {
- id: selectedAccountId,
- },
- accounts: [],
- from,
- to,
- transactions,
- meta: {
- source: TransactionSource.MONOBANK,
- cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS,
- maxRangeSeconds: MONOBANK_MAX_STATEMENT_RANGE_SECONDS,
- },
- });
- } catch (err) {
- next(err);
- }
-}
-
-export async function fetchMonobankAccounts(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const payload = monobankTokenSchema.parse(req.body);
- enforceMonobankCooldown(userId, "client-info");
-
- const clientInfo = await fetchMonobankJson(
- `${MONOBANK_API_BASE_URL}/personal/client-info`,
- payload.token,
- );
-
- const accounts = clientInfo.accounts ?? [];
- if (accounts.length === 0) {
- throw new AppError("No Monobank accounts available for this token", 404);
- }
-
- res.status(200).json({
- accounts: accounts.map((account) => ({
- id: account.id,
- type: account.type,
- currencyCode: account.currencyCode,
- cashbackType: account.cashbackType,
- balance: account.balance,
- creditLimit: account.creditLimit,
- maskedPan: account.maskedPan ?? [],
- iban: account.iban,
- })),
- meta: {
- source: TransactionSource.MONOBANK,
- cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS,
- },
- });
- } catch (err) {
- next(err);
- }
-}
-
-export async function importMonobankTransactions(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const payload = monobankImportPayloadSchema.parse(req.body);
-
- const result = await transactionService.importMonobankTransactions(
- userId,
- payload.transactions,
- );
-
- res.status(200).json({
- ...result,
- source: TransactionSource.MONOBANK,
- });
- } catch (err) {
- next(err);
- }
-}
-
-export async function deleteMonobankTransactions(
- req: Request,
- res: Response,
- next: NextFunction,
-) {
- try {
- const userId = req.user?.id;
- if (!userId) throw new AppError("Unauthorized", 401);
-
- const result =
- await transactionService.deleteAllMonobankTransactions(userId);
- res.status(200).json(result);
- } catch (err) {
- next(err);
- }
-}
+export {
+ getAllTransactions,
+ getTransaction,
+ createTransaction,
+ updateTransaction,
+ deleteTransaction,
+} from "./transactions.controller.js";
+
+export {
+ fetchMonobankAccounts,
+ fetchMonobankTransactions,
+ importMonobankTransactions,
+ deleteMonobankTransactions,
+} from "./monobank.controller.js";
diff --git a/apps/api/src/modules/transaction/monobank.controller.ts b/apps/api/src/modules/transaction/monobank.controller.ts
new file mode 100644
index 0000000..048d625
--- /dev/null
+++ b/apps/api/src/modules/transaction/monobank.controller.ts
@@ -0,0 +1,300 @@
+import type { Request, Response, NextFunction } from "express";
+import { TransactionSource, TransactionType } from "@prisma/client";
+import * as transactionService from "./service.js";
+import { AppError } from "../../middleware/errorHandler.js";
+import {
+ monobankFetchPayloadSchema,
+ monobankImportPayloadSchema,
+ monobankTokenSchema,
+ type MonobankAccount,
+ type MonobankClientInfoResponse,
+ type MonobankStatementItem,
+} from "@fintrack/types";
+
+const MONOBANK_API_BASE_URL = "https://api.monobank.ua";
+const MONOBANK_FETCH_COOLDOWN_SECONDS = 60;
+const MONOBANK_MAX_STATEMENT_RANGE_SECONDS = 2_682_000;
+const monobankClientInfoByUser = new Map();
+const monobankStatementByUser = new Map();
+
+function numericCurrencyToCode(code?: number) {
+ if (code === 980) return "UAH";
+ if (code === 840) return "USD";
+ if (code === 978) return "EUR";
+ return "USD";
+}
+
+function resolveStatementRange(from?: number, to?: number) {
+ const nowUnix = Math.floor(Date.now() / 1000);
+ const resolvedTo = to ?? nowUnix;
+ const resolvedFrom =
+ from ?? resolvedTo - (MONOBANK_MAX_STATEMENT_RANGE_SECONDS - 3600);
+
+ if (resolvedFrom >= resolvedTo) {
+ throw new AppError(
+ "Invalid time range: 'from' must be less than 'to'",
+ 400,
+ );
+ }
+
+ if (resolvedTo - resolvedFrom > MONOBANK_MAX_STATEMENT_RANGE_SECONDS) {
+ throw new AppError(
+ `Invalid time range: max allowed interval is ${MONOBANK_MAX_STATEMENT_RANGE_SECONDS} seconds`,
+ 400,
+ );
+ }
+
+ return { from: resolvedFrom, to: resolvedTo };
+}
+
+function enforceMonobankCooldown(
+ userId: string,
+ operation: "client-info" | "statement",
+) {
+ const map =
+ operation === "client-info"
+ ? monobankClientInfoByUser
+ : monobankStatementByUser;
+ const nowMs = Date.now();
+ const previousRequestMs = map.get(userId);
+
+ if (previousRequestMs) {
+ const diffMs = nowMs - previousRequestMs;
+ const cooldownMs = MONOBANK_FETCH_COOLDOWN_SECONDS * 1000;
+ if (diffMs < cooldownMs) {
+ const retryAfter = Math.ceil((cooldownMs - diffMs) / 1000);
+ throw new AppError(
+ `Monobank request cooldown is ${MONOBANK_FETCH_COOLDOWN_SECONDS}s. Retry after ${retryAfter}s.`,
+ 429,
+ { retryAfter },
+ );
+ }
+ }
+
+ map.set(userId, nowMs);
+}
+
+async function fetchMonobankJson(url: string, token: string): Promise {
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Token": token,
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ let errorPayload: unknown = null;
+ try {
+ errorPayload = await response.json();
+ } catch {
+ errorPayload = await response.text();
+ }
+
+ if (response.status === 429) {
+ throw new AppError(
+ "Monobank rate limit exceeded. Retry in 60 seconds.",
+ 429,
+ {
+ monobank: errorPayload,
+ },
+ );
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ throw new AppError("Invalid Monobank token", 401, {
+ monobank: errorPayload,
+ });
+ }
+
+ throw new AppError(
+ "Failed to fetch data from Monobank API",
+ response.status,
+ {
+ monobank: errorPayload,
+ },
+ );
+ }
+
+ return (await response.json()) as T;
+}
+
+function mapMonobankStatementToPreview(
+ sourceAccountId: string,
+ fallbackCurrencyCodeNumeric: number | undefined,
+ tx: MonobankStatementItem,
+) {
+ const rawAmount = Number(tx.amount);
+ const normalizedAmount = Math.abs(rawAmount) / 100;
+ const type =
+ rawAmount >= 0 ? TransactionType.INCOME : TransactionType.EXPENSE;
+
+ return {
+ title:
+ tx.description?.trim() || tx.comment?.trim() || "Monobank transaction",
+ type,
+ amount: normalizedAmount,
+ currencyCode: numericCurrencyToCode(
+ tx.currencyCode ?? fallbackCurrencyCodeNumeric,
+ ),
+ created_at: new Date(tx.time * 1000),
+ sourceTransactionId: tx.id,
+ sourceAccountId,
+ source: TransactionSource.MONOBANK,
+ };
+}
+
+export async function fetchMonobankTransactions(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const payload = monobankFetchPayloadSchema.parse(req.body);
+ const { from, to } = resolveStatementRange(payload.from, payload.to);
+
+ enforceMonobankCooldown(userId, "statement");
+
+ let selectedAccountId = payload.accountId;
+ let selectedAccount: MonobankAccount | null = null;
+
+ if (!selectedAccountId) {
+ enforceMonobankCooldown(userId, "client-info");
+ const clientInfo = await fetchMonobankJson(
+ `${MONOBANK_API_BASE_URL}/personal/client-info`,
+ payload.token,
+ );
+ const accounts = clientInfo.accounts ?? [];
+ if (accounts.length === 0) {
+ throw new AppError(
+ "No Monobank accounts available for this token",
+ 404,
+ );
+ }
+ selectedAccount = accounts[0] ?? null;
+ selectedAccountId = selectedAccount?.id;
+ }
+
+ if (!selectedAccountId) {
+ throw new AppError("Monobank account id is required", 400);
+ }
+
+ const statement = await fetchMonobankJson(
+ `${MONOBANK_API_BASE_URL}/personal/statement/${selectedAccountId}/${from}/${to}`,
+ payload.token,
+ );
+
+ const transactions = statement.map((tx) =>
+ mapMonobankStatementToPreview(
+ selectedAccountId,
+ payload.accountCurrencyCode,
+ tx,
+ ),
+ );
+
+ res.status(200).json({
+ account: selectedAccount ?? {
+ id: selectedAccountId,
+ },
+ accounts: [],
+ from,
+ to,
+ transactions,
+ meta: {
+ source: TransactionSource.MONOBANK,
+ cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS,
+ maxRangeSeconds: MONOBANK_MAX_STATEMENT_RANGE_SECONDS,
+ },
+ });
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function fetchMonobankAccounts(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const payload = monobankTokenSchema.parse(req.body);
+ enforceMonobankCooldown(userId, "client-info");
+
+ const clientInfo = await fetchMonobankJson(
+ `${MONOBANK_API_BASE_URL}/personal/client-info`,
+ payload.token,
+ );
+
+ const accounts = clientInfo.accounts ?? [];
+ if (accounts.length === 0) {
+ throw new AppError("No Monobank accounts available for this token", 404);
+ }
+
+ res.status(200).json({
+ accounts: accounts.map((account) => ({
+ id: account.id,
+ type: account.type,
+ currencyCode: account.currencyCode,
+ cashbackType: account.cashbackType,
+ balance: account.balance,
+ creditLimit: account.creditLimit,
+ maskedPan: account.maskedPan ?? [],
+ iban: account.iban,
+ })),
+ meta: {
+ source: TransactionSource.MONOBANK,
+ cooldownSeconds: MONOBANK_FETCH_COOLDOWN_SECONDS,
+ },
+ });
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function importMonobankTransactions(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const payload = monobankImportPayloadSchema.parse(req.body);
+
+ const result = await transactionService.importMonobankTransactions(
+ userId,
+ payload.transactions,
+ );
+
+ res.status(200).json({
+ ...result,
+ source: TransactionSource.MONOBANK,
+ });
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function deleteMonobankTransactions(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const result =
+ await transactionService.deleteAllMonobankTransactions(userId);
+ res.status(200).json(result);
+ } catch (err) {
+ next(err);
+ }
+}
diff --git a/apps/api/src/modules/transaction/transactions.controller.ts b/apps/api/src/modules/transaction/transactions.controller.ts
new file mode 100644
index 0000000..049f6fb
--- /dev/null
+++ b/apps/api/src/modules/transaction/transactions.controller.ts
@@ -0,0 +1,241 @@
+import type { Request, Response, NextFunction } from "express";
+import { Prisma, TransactionSource } from "@prisma/client";
+import z from "zod";
+import * as transactionService from "./service.js";
+import { AppError } from "../../middleware/errorHandler.js";
+import {
+ createTransactionSchema,
+ manualCurrencyCodeSchema,
+ updateTransactionSchema,
+} from "@fintrack/types";
+
+const sourceQuerySchema = z
+ .enum([TransactionSource.MANUAL, TransactionSource.MONOBANK])
+ .optional();
+
+function normalizeCurrencyCode(input?: string) {
+ if (!input) return "USD";
+ const normalized = input.trim().toUpperCase();
+ const parsed = manualCurrencyCodeSchema.safeParse(normalized);
+ return parsed.success ? parsed.data : "USD";
+}
+
+function getSourceFilter(rawSource: unknown): TransactionSource | undefined {
+ const parsed = sourceQuerySchema.safeParse(rawSource);
+ if (!parsed.success) {
+ throw new AppError("Invalid source. Allowed values: MANUAL, MONOBANK", 400);
+ }
+
+ return parsed.data;
+}
+
+export async function getAllTransactions(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+ const source = getSourceFilter(req.query.source);
+
+ const hasPage = req.query.page !== undefined;
+ const hasPerPage = req.query.perPage !== undefined;
+
+ if (hasPage !== hasPerPage) {
+ throw new AppError(
+ "Both 'page' and 'perPage' are required for pagination",
+ 400,
+ );
+ }
+
+ if (hasPage && hasPerPage) {
+ const page = Number(req.query.page);
+ const perPage = Number(req.query.perPage);
+ if (
+ Number.isNaN(page) ||
+ Number.isNaN(perPage) ||
+ page < 1 ||
+ perPage < 1
+ ) {
+ throw new AppError("Invalid pagination params", 400);
+ }
+ const transactions = await transactionService.getTransactionsPerPage(
+ userId,
+ page,
+ perPage,
+ source,
+ );
+ res.status(200).json(transactions);
+ return;
+ }
+
+ const transactions = await transactionService.getAllTransactions(
+ userId,
+ source,
+ );
+ res.status(200).json(transactions);
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function getTransaction(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ let transaction: object | null = {};
+ const { id } = req.params;
+ if (!id) throw new AppError("Transaction id is required", 400);
+ if (Array.isArray(id)) {
+ throw new AppError("Invalid id", 400);
+ }
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+ transaction = await transactionService.getTransaction(id, userId);
+ if (!transaction) throw new AppError("Not found", 404);
+ res.status(200).json(transaction);
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function createTransaction(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const validatedBody = createTransactionSchema.parse(req.body);
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const prismaData: Prisma.TransactionCreateInput = {
+ title: validatedBody.title,
+ type: validatedBody.type,
+ amount: validatedBody.amount,
+ currencyCode: normalizeCurrencyCode(validatedBody.currencyCode),
+ ...(validatedBody.created_at
+ ? { created_at: validatedBody.created_at }
+ : {}),
+ ...(validatedBody.updated_at
+ ? { updated_at: validatedBody.updated_at }
+ : {}),
+ user: {
+ connect: { id: userId },
+ },
+ ...(validatedBody.location && {
+ location: {
+ create: {
+ latitude: validatedBody.location.latitude,
+ longitude: validatedBody.location.longitude,
+ },
+ },
+ }),
+ };
+ const transaction = await transactionService.createTransaction(prismaData);
+ res.status(201).json(transaction);
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function updateTransaction(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const { id } = req.params;
+ if (!id) throw new AppError("Transaction id is required", 400);
+ if (Array.isArray(id)) {
+ throw new AppError("Invalid id", 400);
+ }
+ const validatedBody = updateTransactionSchema.parse(req.body);
+
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const transactionOfCurrentUser = await transactionService.getTransaction(
+ id,
+ userId,
+ );
+ if (!transactionOfCurrentUser) throw new AppError("Not found", 404);
+ if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) {
+ throw new AppError("Monobank transactions are read-only", 403);
+ }
+
+ const prismaData: Prisma.TransactionUpdateInput = {};
+ if (validatedBody.title !== undefined)
+ prismaData.title = validatedBody.title;
+ if (validatedBody.type !== undefined) prismaData.type = validatedBody.type;
+ if (validatedBody.amount !== undefined)
+ prismaData.amount = validatedBody.amount;
+ if (validatedBody.currencyCode !== undefined)
+ prismaData.currencyCode = normalizeCurrencyCode(
+ validatedBody.currencyCode,
+ );
+ if (validatedBody.created_at !== undefined)
+ prismaData.created_at = validatedBody.created_at;
+ if (validatedBody.updated_at !== undefined)
+ prismaData.updated_at = validatedBody.updated_at;
+ if (validatedBody.location) {
+ prismaData.location = {
+ upsert: {
+ create: {
+ latitude: validatedBody.location.latitude,
+ longitude: validatedBody.location.longitude,
+ },
+ update: {
+ latitude: validatedBody.location.latitude,
+ longitude: validatedBody.location.longitude,
+ },
+ },
+ };
+ }
+ if (validatedBody.location === null) {
+ prismaData.location = { delete: true };
+ }
+
+ const transaction = await transactionService.updateTransaction(
+ id,
+ prismaData,
+ );
+ res.status(200).json(transaction);
+ } catch (err) {
+ next(err);
+ }
+}
+
+export async function deleteTransaction(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) {
+ try {
+ const { id } = req.params;
+ if (!id) throw new AppError("Transaction id is required", 400);
+ if (Array.isArray(id)) {
+ throw new AppError("Invalid id", 400);
+ }
+
+ const userId = req.user?.id;
+ if (!userId) throw new AppError("Unauthorized", 401);
+
+ const transactionOfCurrentUser = await transactionService.getTransaction(
+ id,
+ userId,
+ );
+ if (!transactionOfCurrentUser) throw new AppError("Not found", 404);
+ if (transactionOfCurrentUser.source === TransactionSource.MONOBANK) {
+ throw new AppError("Monobank transactions are read-only", 403);
+ }
+
+ await transactionService.deleteTransaction(id);
+ res.sendStatus(204);
+ } catch (err) {
+ next(err);
+ }
+}
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 6ffade7..a18219f 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -3,13 +3,13 @@ WORKDIR /app
FROM base AS deps
RUN apk add --no-cache libc6-compat
+ENV HUSKY=0
COPY package.json package-lock.json turbo.json ./
COPY apps/web/package.json apps/web/package.json
COPY packages/types/package.json packages/types/package.json
RUN npm ci --include=dev --install-strategy=nested
-RUN npm ci --workspace fintrack-web --include=dev --install-strategy=nested
FROM base AS builder
ENV NEXT_TELEMETRY_DISABLED=1
@@ -47,4 +47,4 @@ COPY --from=builder /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 5173
-CMD ["node", "apps/web/server.js"]
+CMD ["node", "apps/web/server.js"]
\ No newline at end of file
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/package.json b/apps/web/package.json
index 5bd3fd0..91240b4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -49,6 +49,9 @@
"typescript-eslint": "^8.35.1"
},
"optionalDependencies": {
- "lightningcss-linux-x64-gnu": "*"
+ "lightningcss-linux-x64-gnu": "*",
+ "lightningcss-linux-x64-musl": "*",
+ "@tailwindcss/oxide-linux-x64-gnu": "*",
+ "@tailwindcss/oxide-linux-x64-musl": "*"
}
}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx b/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx
new file mode 100644
index 0000000..2b2cc62
--- /dev/null
+++ b/apps/web/src/app/(protected)/admin/_components/AdminChartCard.tsx
@@ -0,0 +1,85 @@
+import type { ChartData } from "chart.js";
+import { Bar, Doughnut } from "react-chartjs-2";
+
+export function AdminDoughnutCard({
+ title,
+ data,
+}: {
+ title: string;
+ data: ChartData<"doughnut">;
+}) {
+ return (
+
+ );
+}
+
+export function AdminBarCard({
+ title,
+ data,
+}: {
+ title: string;
+ data: ChartData<"bar">;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx
new file mode 100644
index 0000000..dcec3bb
--- /dev/null
+++ b/apps/web/src/app/(protected)/admin/_components/AdminErrorLogsSection.tsx
@@ -0,0 +1,151 @@
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import type { AdminErrorLog, ErrorLogStatus } from "@fintrack/types";
+import { formatDate } from "@/utils/admin";
+
+interface AdminErrorLogsSectionProps {
+ logStatusFilter: "ALL" | ErrorLogStatus;
+ setLogStatusFilter: (value: "ALL" | ErrorLogStatus) => void;
+ errorLogs: AdminErrorLog[];
+ isLoading: boolean;
+ errorMessage?: string;
+ onToggleResolved: (errorLogId: string, resolved: boolean) => void;
+ isResolvePendingForLog: (errorLogId: string) => boolean;
+}
+
+export function AdminErrorLogsSection({
+ logStatusFilter,
+ setLogStatusFilter,
+ errorLogs,
+ isLoading,
+ errorMessage,
+ onToggleResolved,
+ isResolvePendingForLog,
+}: AdminErrorLogsSectionProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+ {t("admin.errors.title")}
+
+
+
+
+
+
+
+
+ {isLoading && (
+
{t("admin.errors.loading")}
+ )}
+ {!!errorMessage &&
{errorMessage}
}
+
+ {!isLoading && !errorLogs.length && (
+
{t("admin.errors.empty")}
+ )}
+
+ {!!errorLogs.length && (
+
+ {errorLogs.map((log) => (
+
+
+
+
+ {log.title}
+
+
+ {log.user.name} ({log.user.role}) |{" "}
+ {formatDate(log.createdAt)}
+
+
+
+ {log.status}
+
+
+
+
+ {log.message}
+
+
+ {log.stack && (
+
+ {log.stack}
+
+ )}
+
+
+
+ {t("admin.errors.source")}: {log.source || "-"}
+
+ |
+
+ {t("admin.errors.userAgent")}: {log.userAgent || "-"}
+
+ |
+
+ {t("admin.errors.resolvedAt")}:{" "}
+ {log.resolvedAt ? formatDate(log.resolvedAt) : "-"}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx b/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx
index 50b82ad..973c30b 100644
--- a/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx
+++ b/apps/web/src/app/(protected)/admin/_components/AdminInsightsCharts.tsx
@@ -11,10 +11,10 @@ import {
Tooltip,
type ChartData,
} from "chart.js";
-import { Bar, Doughnut } from "react-chartjs-2";
import { useMemo } from "react";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
import type { AdminErrorLog, AdminUser } from "@fintrack/types";
+import { AdminBarCard, AdminDoughnutCard } from "./AdminChartCard";
ChartJS.register(
CategoryScale,
@@ -156,89 +156,18 @@ export function AdminInsightsCharts({
) : (
-
-
- {t("admin.charts.rolesBreakdown")}
-
-
-
-
-
-
-
-
- {t("admin.charts.verificationBreakdown")}
-
-
-
-
-
-
-
-
- {t("admin.charts.activity14d")}
-
-
-
-
-
+
+
+
)}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx
new file mode 100644
index 0000000..847d7a3
--- /dev/null
+++ b/apps/web/src/app/(protected)/admin/_components/AdminOverviewSection.tsx
@@ -0,0 +1,93 @@
+import type { AdminErrorLog, AdminStats, AdminUser } from "@fintrack/types";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import { AdminInsightsCharts } from "./AdminInsightsCharts";
+
+interface AdminOverviewSectionProps {
+ stats?: AdminStats;
+ users: AdminUser[];
+ chartLogs: AdminErrorLog[];
+ isStatsLoading: boolean;
+ isChartsLoading: boolean;
+}
+
+export function AdminOverviewSection({
+ stats,
+ users,
+ chartLogs,
+ isStatsLoading,
+ isChartsLoading,
+}: AdminOverviewSectionProps) {
+ const { t } = useSafeTranslation();
+
+ const metrics = !stats
+ ? []
+ : [
+ {
+ id: "totalUsers",
+ label: t("admin.metrics.totalUsers"),
+ value: stats.users.total,
+ },
+ {
+ id: "admins",
+ label: t("admin.metrics.admins"),
+ value: stats.users.admins,
+ },
+ {
+ id: "verifiedUsers",
+ label: t("admin.metrics.verifiedUsers"),
+ value: stats.users.verified,
+ },
+ {
+ id: "newUsers7d",
+ label: t("admin.metrics.newUsers7d"),
+ value: stats.users.newLast7Days,
+ },
+ {
+ id: "activeSessions",
+ label: t("admin.metrics.activeSessions"),
+ value: stats.sessions.active,
+ },
+ {
+ id: "openErrors",
+ label: t("admin.metrics.openErrors"),
+ value: stats.errors.open,
+ },
+ ];
+
+ return (
+ <>
+
+
+ {t("admin.title")}
+
+
+ {t("admin.subtitle")}
+
+
+
+
+ {isStatsLoading && (
+
+ {t("admin.loadingStats")}
+
+ )}
+ {metrics.map((metric) => (
+
+
+ {metric.label}
+
+
+ {metric.value}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx b/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx
index d3a06ff..7702c3f 100644
--- a/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx
+++ b/apps/web/src/app/(protected)/admin/_components/AdminPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useState } from "react";
+import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getAdminErrorLogs,
@@ -13,31 +13,10 @@ import {
} from "@/api/admin";
import { useAuth } from "@/hooks/useAuth";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
-import type { AdminUser, ErrorLogStatus } from "@fintrack/types";
-import { AdminInsightsCharts } from "./AdminInsightsCharts";
-
-function formatDate(value: Date | null) {
- if (!value) return "-";
- return new Intl.DateTimeFormat(undefined, {
- dateStyle: "medium",
- timeStyle: "short",
- }).format(value);
-}
-
-function getPreferredUserContact(user: AdminUser) {
- const emailMethod = user.authMethods.find((item) => item.type === "EMAIL");
- if (emailMethod?.email) return emailMethod.email;
-
- const telegramMethod = user.authMethods.find(
- (item) => item.type === "TELEGRAM",
- );
- if (telegramMethod?.telegram_id) return `tg:${telegramMethod.telegram_id}`;
-
- const googleMethod = user.authMethods.find((item) => item.type === "GOOGLE");
- if (googleMethod?.google_sub) return `google:${googleMethod.google_sub}`;
-
- return "-";
-}
+import type { ErrorLogStatus } from "@fintrack/types";
+import { AdminUsersSection } from "./AdminUsersSection";
+import { AdminErrorLogsSection } from "./AdminErrorLogsSection";
+import { AdminOverviewSection } from "./AdminOverviewSection";
export function AdminPanel() {
const { t } = useSafeTranslation();
@@ -138,43 +117,6 @@ export function AdminPanel() {
const selfUserId = user?.id;
- const metrics = useMemo(() => {
- if (!statsQuery.data) return [];
-
- return [
- {
- id: "totalUsers",
- label: t("admin.metrics.totalUsers"),
- value: statsQuery.data.users.total,
- },
- {
- id: "admins",
- label: t("admin.metrics.admins"),
- value: statsQuery.data.users.admins,
- },
- {
- id: "verifiedUsers",
- label: t("admin.metrics.verifiedUsers"),
- value: statsQuery.data.users.verified,
- },
- {
- id: "newUsers7d",
- label: t("admin.metrics.newUsers7d"),
- value: statsQuery.data.users.newLast7Days,
- },
- {
- id: "activeSessions",
- label: t("admin.metrics.activeSessions"),
- value: statsQuery.data.sessions.active,
- },
- {
- id: "openErrors",
- label: t("admin.metrics.openErrors"),
- value: statsQuery.data.errors.open,
- },
- ];
- }, [statsQuery.data, t]);
-
if (!isAdmin) {
return (
@@ -190,419 +132,57 @@ export function AdminPanel() {
return (
-
-
- {t("admin.title")}
-
-
- {t("admin.subtitle")}
-
-
-
-
- {statsQuery.isLoading && (
-
- {t("admin.loadingStats")}
-
- )}
- {metrics.map((metric) => (
-
-
- {metric.label}
-
-
- {metric.value}
-
-
- ))}
-
-
-
-
-
-
- {t("admin.users.title")}
-
-
-
-
- {usersQuery.isLoading && (
-
{t("admin.users.loading")}
- )}
- {usersQuery.isError && (
-
- {(usersQuery.error as Error).message || t("admin.users.loadError")}
-
- )}
-
- {!usersQuery.isLoading && !users.length && (
-
{t("admin.users.empty")}
- )}
-
- {!!users.length && (
-
-
-
-
- |
- {t("admin.users.columns.name")}
- |
-
- {t("admin.users.columns.contact")}
- |
-
- {t("admin.users.columns.role")}
- |
-
- {t("admin.users.columns.verified")}
- |
-
- {t("admin.users.columns.created")}
- |
-
- {t("admin.users.columns.actions")}
- |
-
-
-
- {users.map((adminUser) => {
- const canDemote =
- adminUser.role === "ADMIN" && adminUser.id !== selfUserId;
-
- return (
-
- |
- {adminUser.name}
- |
-
- {getPreferredUserContact(adminUser)}
- |
-
- {adminUser.role}
- |
-
- {adminUser.isVerified
- ? t("admin.users.yes")
- : t("admin.users.no")}
- |
-
- {formatDate(adminUser.created_at)}
- |
-
-
- {adminUser.role === "USER" && (
-
- )}
-
- {canDemote && (
-
- )}
-
-
-
- |
-
- );
- })}
-
-
-
- )}
-
- {!!users.length && (
-
- {users.map((adminUser) => {
- const canDemote =
- adminUser.role === "ADMIN" && adminUser.id !== selfUserId;
-
- return (
-
-
-
-
-
- {adminUser.name}
-
-
-
-
- {getPreferredUserContact(adminUser)}
-
-
-
-
- {adminUser.role}
-
-
-
-
-
- {t("admin.users.columns.verified")}:{" "}
- {adminUser.isVerified
- ? t("admin.users.yes")
- : t("admin.users.no")}
-
-
- {t("admin.users.columns.created")}:{" "}
- {formatDate(adminUser.created_at)}
-
-
-
-
- {adminUser.role === "USER" && (
-
- )}
-
- {canDemote && (
-
- )}
-
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
- {t("admin.errors.title")}
-
-
-
-
-
-
-
-
- {errorLogsQuery.isLoading && (
-
{t("admin.errors.loading")}
- )}
- {errorLogsQuery.isError && (
-
- {(errorLogsQuery.error as Error).message ||
- t("admin.errors.loadError")}
-
- )}
-
- {!errorLogsQuery.isLoading && !errorLogs.length && (
-
{t("admin.errors.empty")}
- )}
-
- {!!errorLogs.length && (
-
- {errorLogs.map((log) => (
-
-
-
-
- {log.title}
-
-
- {log.user.name} ({log.user.role}) |{" "}
- {formatDate(log.createdAt)}
-
-
-
- {log.status}
-
-
-
-
- {log.message}
-
-
- {log.stack && (
-
- {log.stack}
-
- )}
-
-
-
- {t("admin.errors.source")}: {log.source || "-"}
-
- |
-
- {t("admin.errors.userAgent")}: {log.userAgent || "-"}
-
- |
-
- {t("admin.errors.resolvedAt")}:{" "}
- {log.resolvedAt ? formatDate(log.resolvedAt) : "-"}
-
-
+ revokeAllSessionsMutation.mutate()}
+ onPromote={(userId) => roleMutation.mutate({ userId, role: "ADMIN" })}
+ onDemote={(userId) => roleMutation.mutate({ userId, role: "USER" })}
+ onRevokeUserSessions={(userId) =>
+ revokeUserSessionsMutation.mutate(userId)
+ }
+ isRolePendingForUser={(userId) =>
+ roleMutation.isPending && roleChangingUserId === userId
+ }
+ isSessionPendingForUser={(userId) =>
+ revokeUserSessionsMutation.isPending &&
+ sessionRevokingUserId === userId
+ }
+ />
-
-
-
-
- ))}
-
- )}
-
+
+ resolveLogMutation.mutate({ errorLogId, resolved })
+ }
+ isResolvePendingForLog={(errorLogId) =>
+ resolveLogMutation.isPending && resolvingLogId === errorLogId
+ }
+ />
);
}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx b/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx
new file mode 100644
index 0000000..8441147
--- /dev/null
+++ b/apps/web/src/app/(protected)/admin/_components/AdminUserActions.tsx
@@ -0,0 +1,59 @@
+import type { AdminUser } from "@fintrack/types";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface AdminUserActionsProps {
+ adminUser: AdminUser;
+ canDemote: boolean;
+ onPromote: (userId: string) => void;
+ onDemote: (userId: string) => void;
+ onRevokeUserSessions: (userId: string) => void;
+ isRolePending: boolean;
+ isSessionPending: boolean;
+}
+
+export function AdminUserActions({
+ adminUser,
+ canDemote,
+ onPromote,
+ onDemote,
+ onRevokeUserSessions,
+ isRolePending,
+ isSessionPending,
+}: AdminUserActionsProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+ {adminUser.role === "USER" && (
+
+ )}
+
+ {canDemote && (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx b/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx
new file mode 100644
index 0000000..b4a173c
--- /dev/null
+++ b/apps/web/src/app/(protected)/admin/_components/AdminUsersSection.tsx
@@ -0,0 +1,193 @@
+import type { AdminUser } from "@fintrack/types";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import { AdminUserActions } from "./AdminUserActions";
+import { formatDate, getPreferredUserContact } from "@/utils/admin";
+
+interface AdminUsersSectionProps {
+ users: AdminUser[];
+ selfUserId?: string;
+ isLoading: boolean;
+ errorMessage?: string;
+ isRevokingAll: boolean;
+ onRevokeAll: () => void;
+ onPromote: (userId: string) => void;
+ onDemote: (userId: string) => void;
+ onRevokeUserSessions: (userId: string) => void;
+ isRolePendingForUser: (userId: string) => boolean;
+ isSessionPendingForUser: (userId: string) => boolean;
+}
+
+export function AdminUsersSection({
+ users,
+ selfUserId,
+ isLoading,
+ errorMessage,
+ isRevokingAll,
+ onRevokeAll,
+ onPromote,
+ onDemote,
+ onRevokeUserSessions,
+ isRolePendingForUser,
+ isSessionPendingForUser,
+}: AdminUsersSectionProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+ {t("admin.users.title")}
+
+
+
+
+ {isLoading && (
+
{t("admin.users.loading")}
+ )}
+ {!!errorMessage &&
{errorMessage}
}
+
+ {!isLoading && !users.length && (
+
{t("admin.users.empty")}
+ )}
+
+ {!!users.length && (
+
+
+
+
+ |
+ {t("admin.users.columns.name")}
+ |
+
+ {t("admin.users.columns.contact")}
+ |
+
+ {t("admin.users.columns.role")}
+ |
+
+ {t("admin.users.columns.verified")}
+ |
+
+ {t("admin.users.columns.created")}
+ |
+
+ {t("admin.users.columns.actions")}
+ |
+
+
+
+ {users.map((adminUser) => {
+ const canDemote =
+ adminUser.role === "ADMIN" && adminUser.id !== selfUserId;
+
+ return (
+
+ |
+ {adminUser.name}
+ |
+
+ {getPreferredUserContact(adminUser)}
+ |
+
+ {adminUser.role}
+ |
+
+ {adminUser.isVerified
+ ? t("admin.users.yes")
+ : t("admin.users.no")}
+ |
+
+ {formatDate(adminUser.created_at)}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+ {!!users.length && (
+
+ {users.map((adminUser) => {
+ const canDemote =
+ adminUser.role === "ADMIN" && adminUser.id !== selfUserId;
+
+ return (
+
+
+
+
+
+ {adminUser.name}
+
+
+
+
+ {getPreferredUserContact(adminUser)}
+
+
+
+
+ {adminUser.role}
+
+
+
+
+
+ {t("admin.users.columns.verified")}:{" "}
+ {adminUser.isVerified
+ ? t("admin.users.yes")
+ : t("admin.users.no")}
+
+
+ {t("admin.users.columns.created")}:{" "}
+ {formatDate(adminUser.created_at)}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx
index f77a6bf..5ddb4cb 100644
--- a/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx
+++ b/apps/web/src/app/(protected)/analytics/_components/Analytics.tsx
@@ -1,22 +1,19 @@
import { useCallback, useEffect, useState } from "react";
-import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
-import { CustomMessage, NoData, Spinner } from "@/shared/ui/Helpers";
-import { motion } from "framer-motion";
-import { sanitizeText, toLocalDatetimeString } from "@/utils/components";
-import { TypingText } from "./TypingText";
-import { FixedPanel } from "@/shared/portals/FixedPanel";
import { useAnalyticsAI } from "@/hooks/useAnalyticsAI";
import { useTransactionsAll } from "@/hooks/useTransactions";
import { useUserApiKey } from "@/hooks/useUserApiKey";
-import { ApiKeyPopup } from "./ApiKeyPopup";
-import { AiErrorPopup } from "./AiErrorPopup";
-import { AiLimitPopup } from "./AiLimitPopup";
-import type { AIResponseWithDiff, AiErrorCode } from "@/types/ai";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
import { usePopupStore } from "@/store/popup";
import { useAiAccess } from "@/hooks/useAiAccess";
+import type { AiErrorCode } from "@/types/ai";
import type { ApiError } from "@/types/api";
+import { ApiKeyPopup } from "./ApiKeyPopup";
+import { AiErrorPopup } from "./AiErrorPopup";
+import { AiLimitPopup } from "./AiLimitPopup";
+import { AnalyticsContent } from "./AnalyticsContent";
+import { AnalyticsStateBoundary } from "./AnalyticsStateBoundary";
+import { getLatestMessageIndex, isAiErrorCode } from "@/utils/analytics";
export function Analytics() {
const { t } = useSafeTranslation();
@@ -40,16 +37,6 @@ export function Analytics() {
const { history, isLoading: isLoadingAI, getResponse } = useAnalyticsAI();
- function isAiErrorCode(value: unknown): value is AiErrorCode {
- return (
- value === "USER_KEY_LIMIT" ||
- value === "USER_KEY_INVALID" ||
- value === "DEFAULT_KEY_LIMIT" ||
- value === "ALL_KEYS_FAILED" ||
- value === "USING_DEFAULT_KEY"
- );
- }
-
const handleOpenErrorPopup = useCallback(
(errorCode: AiErrorCode) => {
open(t("errors.error"), );
@@ -57,19 +44,7 @@ export function Analytics() {
[open, t],
);
- const latestMessageIndex = history.reduce((latestIndex, message, index) => {
- const currentTime = new Date(message.getted_at).getTime();
- if (!Number.isFinite(currentTime)) return latestIndex;
-
- if (latestIndex < 0) return index;
-
- const latestTime = new Date(history[latestIndex]?.getted_at).getTime();
- if (!Number.isFinite(latestTime) || currentTime > latestTime) {
- return index;
- }
-
- return latestIndex;
- }, -1);
+ const latestMessageIndex = getLatestMessageIndex(history);
const handleAnalyze = useCallback(async () => {
if (!prompt || !transactionData?.data.length) return;
@@ -130,184 +105,29 @@ export function Analytics() {
open(t("analytics.addApiKey"), );
};
- const formatAnalyticsDate = (dateInput: Date) => {
- const date = new Date(dateInput);
- if (Number.isFinite(date.getTime())) {
- return toLocalDatetimeString(date, true);
- }
- return toLocalDatetimeString(new Date(), true);
- };
-
- if (isLoading || isLoadingTransactions) return ;
- if (!user) return ;
- if (error) return ;
- if (!transactionData) return ;
-
return (
-
-
- {/* Header */}
-
-
- {t("analytics.title")}
-
-
-
-
-
-
- {access?.isUnlimited ? (
-
- Unlimited AI analytics access is active.
-
- ) : (
-
-
- Remaining attempts:{" "}
-
- {access?.remainingAttempts ?? 0}
- {" "}
- of {access?.aiAnalysisLimit ?? 10}
-
-
- Donation Stripe
-
-
- )}
-
-
- {isLimitReached && (
-
-
-
- Free AI limit is exhausted. Donate to unlock unlimited access.
-
-
- Open Donation Stripe
-
-
-
- )}
-
- {isLoadingAI && (
-
-
-
- )}
-
- {/* History analysis */}
-
- {history.map((item: AIResponseWithDiff, index) => {
- const currentDate = formatAnalyticsDate(item.getted_at);
- const isLatestMessage = index === latestMessageIndex;
-
- return (
-
- {/* User Message */}
-
-
- {user.photo_url && (
-

- )}
-
{item.prompt}
-
-
- {currentDate}
-
-
-
- {/* AI Response */}
-
- {isLatestMessage ? (
-
- ) : (
-
- {sanitizeText(item.result)}
-
- )}
-
- {currentDate}
-
-
-
- );
- })}
-
-
-
-
-
-
-
-
-
-
+
+ void handleAnalyze()}
+ onOpenApiKeyPopup={handleOpenAPIKeyPopup}
+ />
+
);
}
diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsContent.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsContent.tsx
new file mode 100644
index 0000000..ef9d747
--- /dev/null
+++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsContent.tsx
@@ -0,0 +1,73 @@
+import { Spinner } from "@/shared/ui/Helpers";
+import { AnalyticsHeaderAndAccess } from "./AnalyticsHeaderAndAccess";
+import { AnalyticsHistoryList } from "./AnalyticsHistoryList";
+import { AnalyticsPromptPanel } from "./AnalyticsPromptPanel";
+import type { AIResponseWithDiff } from "@/types/ai";
+import type { UserResponse } from "@fintrack/types";
+
+interface AnalyticsContentProps {
+ user: UserResponse;
+ history: AIResponseWithDiff[];
+ hasActiveKey: boolean;
+ isLimitReached: boolean;
+ isUnlimited: boolean;
+ remainingAttempts: number;
+ aiAnalysisLimit: number;
+ latestMessageIndex: number;
+ isLoadingAI: boolean;
+ prompt: string;
+ setPrompt: (value: string) => void;
+ onAnalyze: () => void;
+ onOpenApiKeyPopup: () => void;
+}
+
+export function AnalyticsContent({
+ user,
+ history,
+ hasActiveKey,
+ isLimitReached,
+ isUnlimited,
+ remainingAttempts,
+ aiAnalysisLimit,
+ latestMessageIndex,
+ isLoadingAI,
+ prompt,
+ setPrompt,
+ onAnalyze,
+ onOpenApiKeyPopup,
+}: AnalyticsContentProps) {
+ return (
+
+
+
+
+ {isLoadingAI && (
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHeaderAndAccess.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHeaderAndAccess.tsx
new file mode 100644
index 0000000..ebb3c08
--- /dev/null
+++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHeaderAndAccess.tsx
@@ -0,0 +1,98 @@
+import Link from "next/link";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface AnalyticsHeaderAndAccessProps {
+ hasActiveKey: boolean;
+ isLimitReached: boolean;
+ isUnlimited: boolean;
+ remainingAttempts: number;
+ aiAnalysisLimit: number;
+ onOpenApiKeyPopup: () => void;
+}
+
+export function AnalyticsHeaderAndAccess({
+ hasActiveKey,
+ isLimitReached,
+ isUnlimited,
+ remainingAttempts,
+ aiAnalysisLimit,
+ onOpenApiKeyPopup,
+}: AnalyticsHeaderAndAccessProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+ <>
+
+
+ {t("analytics.title")}
+
+
+
+
+
+
+ {isUnlimited ? (
+
+ Unlimited AI analytics access is active.
+
+ ) : (
+
+
+ Remaining attempts:{" "}
+
+ {remainingAttempts}
+ {" "}
+ of {aiAnalysisLimit}
+
+
+ Donation Stripe
+
+
+ )}
+
+
+ {isLimitReached && (
+
+
+
+ Free AI limit is exhausted. Donate to unlock unlimited access.
+
+
+ Open Donation Stripe
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx
new file mode 100644
index 0000000..d57a0bf
--- /dev/null
+++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsHistoryList.tsx
@@ -0,0 +1,66 @@
+import { motion } from "framer-motion";
+import { sanitizeText } from "@/utils/components";
+import { TypingText } from "./TypingText";
+import type { AIResponseWithDiff } from "@/types/ai";
+import type { UserResponse } from "@fintrack/types";
+import { formatAnalyticsDate } from "@/utils/analytics";
+
+interface AnalyticsHistoryListProps {
+ history: AIResponseWithDiff[];
+ latestMessageIndex: number;
+ user: UserResponse;
+}
+
+export function AnalyticsHistoryList({
+ history,
+ latestMessageIndex,
+ user,
+}: AnalyticsHistoryListProps) {
+ return (
+
+ {history.map((item: AIResponseWithDiff, index) => {
+ const currentDate = formatAnalyticsDate(item.getted_at);
+ const isLatestMessage = index === latestMessageIndex;
+
+ return (
+
+
+
+ {user.photo_url && (
+

+ )}
+
{item.prompt}
+
+
+ {currentDate}
+
+
+
+
+ {isLatestMessage ? (
+
+ ) : (
+
+ {sanitizeText(item.result)}
+
+ )}
+
+ {currentDate}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsPromptPanel.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsPromptPanel.tsx
new file mode 100644
index 0000000..7b0656b
--- /dev/null
+++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsPromptPanel.tsx
@@ -0,0 +1,57 @@
+import { motion } from "framer-motion";
+import { FixedPanel } from "@/shared/portals/FixedPanel";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface AnalyticsPromptPanelProps {
+ prompt: string;
+ setPrompt: (value: string) => void;
+ onAnalyze: () => void;
+ isLoadingAI: boolean;
+ isLimitReached: boolean;
+}
+
+export function AnalyticsPromptPanel({
+ prompt,
+ setPrompt,
+ onAnalyze,
+ isLoadingAI,
+ isLimitReached,
+}: AnalyticsPromptPanelProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/analytics/_components/AnalyticsStateBoundary.tsx b/apps/web/src/app/(protected)/analytics/_components/AnalyticsStateBoundary.tsx
new file mode 100644
index 0000000..1379c2a
--- /dev/null
+++ b/apps/web/src/app/(protected)/analytics/_components/AnalyticsStateBoundary.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import type { UserResponse, TransactionsListResponse } from "@fintrack/types";
+import { CustomMessage, NoData, Spinner } from "@/shared/ui/Helpers";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface AnalyticsStateBoundaryProps {
+ isAuthLoading: boolean;
+ isTransactionsLoading: boolean;
+ user?: UserResponse;
+ transactionsError: boolean;
+ transactionData?: TransactionsListResponse;
+ children: ReactNode;
+}
+
+export function AnalyticsStateBoundary({
+ isAuthLoading,
+ isTransactionsLoading,
+ user,
+ transactionsError,
+ transactionData,
+ children,
+}: AnalyticsStateBoundaryProps) {
+ const { t } = useSafeTranslation();
+
+ if (isAuthLoading || isTransactionsLoading) return ;
+ if (!user) return ;
+ if (transactionsError)
+ return ;
+ if (!transactionData) return ;
+
+ return <>{children}>;
+}
diff --git a/apps/web/src/app/(protected)/donation/_components/Donation.tsx b/apps/web/src/app/(protected)/donation/_components/Donation.tsx
index 69174b7..4ba8526 100644
--- a/apps/web/src/app/(protected)/donation/_components/Donation.tsx
+++ b/apps/web/src/app/(protected)/donation/_components/Donation.tsx
@@ -14,6 +14,8 @@ import { queryClient } from "@/api/queryClient";
import { useAuth } from "@/hooks/useAuth";
import { CustomMessage, Spinner } from "@/shared/ui/Helpers";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import { DonationCards } from "./DonationCards";
+import { DonationLeaderboard } from "./DonationLeaderboard";
function createIdempotencyKey() {
return crypto.randomUUID();
@@ -91,22 +93,6 @@ export function Donation() {
}, [access?.tier, t]);
const leaderboardItems = leaderboardQuery.data?.items ?? [];
- const marqueeItems =
- leaderboardItems.length > 1
- ? [...leaderboardItems, ...leaderboardItems]
- : leaderboardItems;
-
- const formatDonationAmount = (amountMinor: number, currency: string) => {
- const normalized = currency?.toUpperCase() || "USD";
- try {
- return new Intl.NumberFormat(undefined, {
- style: "currency",
- currency: normalized,
- }).format(amountMinor / 100);
- } catch {
- return `${(amountMinor / 100).toFixed(2)} ${normalized}`;
- }
- };
if (isLoading) return ;
if (!user) return ;
@@ -122,126 +108,19 @@ export function Donation() {
-
-
-
- {t("donation.benefitsTitle")}
-
-
- - {t("donation.benefit1")}
- - {t("donation.benefit2")}
- - {t("donation.benefit3")}
-
-
-
-
-
-
-
- {t("donation.accessStatusTitle")}
-
-
- {t("donation.currentTier")}:{" "}
-
- {tierLabel}
-
-
-
- {access?.isUnlimited ? (
-
- {t("donation.unlimitedActive")}
-
- ) : (
-
- {t("donation.remainingAttempts", {
- remaining: access?.remainingAttempts ?? 0,
- limit: access?.aiAnalysisLimit ?? 10,
- })}
-
- )}
-
-
- {t("donation.faq")}
-
-
-
-
-
-
- {t("donation.leaderboardTitle")}
-
-
- {t("donation.leaderboardSubtitle")}
-
-
- {leaderboardItems.length > 0 && !leaderboardQuery.isLoading && (
-
-
1
- ? "marqueeScroll 28s linear infinite"
- : "none",
- }}
- >
- {marqueeItems.map((item, index) => (
-
-
-
- {item.photoUrl ? (
-

- ) : (
-
- {item.name.slice(0, 2).toUpperCase()}
-
- )}
-
-
- {item.name}
-
-
- {t("donation.totalDonated")}
-
-
-
-
-
- {formatDonationAmount(
- item.totalAmountMinor,
- item.currency,
- )}
-
-
-
- ))}
-
-
- )}
-
+ checkoutMutation.mutate()}
+ tierLabel={tierLabel}
+ isUnlimited={access?.isUnlimited ?? false}
+ remainingAttempts={access?.remainingAttempts ?? 0}
+ aiAnalysisLimit={access?.aiAnalysisLimit ?? 10}
+ />
+
+
);
}
diff --git a/apps/web/src/app/(protected)/donation/_components/DonationCards.tsx b/apps/web/src/app/(protected)/donation/_components/DonationCards.tsx
new file mode 100644
index 0000000..331f83a
--- /dev/null
+++ b/apps/web/src/app/(protected)/donation/_components/DonationCards.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface DonationCardsProps {
+ isCheckoutPending: boolean;
+ onDonate: () => void;
+ tierLabel: string;
+ isUnlimited: boolean;
+ remainingAttempts: number;
+ aiAnalysisLimit: number;
+}
+
+export function DonationCards({
+ isCheckoutPending,
+ onDonate,
+ tierLabel,
+ isUnlimited,
+ remainingAttempts,
+ aiAnalysisLimit,
+}: DonationCardsProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+ {t("donation.benefitsTitle")}
+
+
+ - {t("donation.benefit1")}
+ - {t("donation.benefit2")}
+ - {t("donation.benefit3")}
+
+
+
+
+
+
+
+ {t("donation.accessStatusTitle")}
+
+
+ {t("donation.currentTier")}:{" "}
+
+ {tierLabel}
+
+
+
+ {isUnlimited ? (
+
+ {t("donation.unlimitedActive")}
+
+ ) : (
+
+ {t("donation.remainingAttempts", {
+ remaining: remainingAttempts,
+ limit: aiAnalysisLimit,
+ })}
+
+ )}
+
+
+ {t("donation.faq")}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/donation/_components/DonationLeaderboard.tsx b/apps/web/src/app/(protected)/donation/_components/DonationLeaderboard.tsx
new file mode 100644
index 0000000..2d620d5
--- /dev/null
+++ b/apps/web/src/app/(protected)/donation/_components/DonationLeaderboard.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import type { DonationLeaderboardItem } from "@/types/donation";
+import { formatDonationAmount } from "@/utils/donation";
+
+interface DonationLeaderboardProps {
+ items: DonationLeaderboardItem[];
+ isLoading: boolean;
+}
+
+export function DonationLeaderboard({
+ items,
+ isLoading,
+}: DonationLeaderboardProps) {
+ const { t } = useSafeTranslation();
+ const marqueeItems = items.length > 1 ? [...items, ...items] : items;
+
+ return (
+
+
+ {t("donation.leaderboardTitle")}
+
+
+ {t("donation.leaderboardSubtitle")}
+
+
+ {items.length > 0 && !isLoading && (
+
+
1 ? "marqueeScroll 28s linear infinite" : "none",
+ }}
+ >
+ {marqueeItems.map((item, index) => (
+
+
+
+ {item.photoUrl ? (
+

+ ) : (
+
+ {item.name.slice(0, 2).toUpperCase()}
+
+ )}
+
+
+ {item.name}
+
+
+ {t("donation.totalDonated")}
+
+
+
+
+
+ {formatDonationAmount(item.totalAmountMinor, item.currency)}
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/Monobank.tsx b/apps/web/src/app/(protected)/monobank/_components/Monobank.tsx
index 8069881..5be9dec 100644
--- a/apps/web/src/app/(protected)/monobank/_components/Monobank.tsx
+++ b/apps/web/src/app/(protected)/monobank/_components/Monobank.tsx
@@ -1,432 +1,18 @@
"use client";
import { useMemo, useState } from "react";
-import Select from "react-select";
-import {
- Chart as ChartJS,
- BarElement,
- CategoryScale,
- LinearScale,
- Tooltip,
- Legend,
-} from "chart.js";
-import { Bar } from "react-chartjs-2";
-import type { Range } from "@fintrack/types";
import { useAuth } from "@/hooks/useAuth";
import {
useMonobankMutations,
useTransactionsAll,
} from "@/hooks/useTransactions";
import { usePopupStore } from "@/store/popup";
-import type { MonobankPreviewTransaction } from "@/types/monobank";
import { CustomMessage } from "@/shared/ui/Helpers";
-import { MonobankAccountSelectPopup } from "./MonobankAccountSelectPopup";
-import { MonobankResultPopup } from "./MonobankResultPopup";
import { useMonobankCooldown } from "@/hooks/useMonobankCooldown";
-import { useCurrency } from "@/hooks/useCurrency";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
-
-ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
-
-const CHART_INCOME_COLOR = "#00c07a";
-const CHART_OUTCOME_COLOR = "#ff4d5f";
-
-type StatsTransaction = {
- id: string;
- title: string;
- type: "INCOME" | "EXPENSE";
- amount: number;
- currencyCode: string;
- createdAt: Date;
-};
-
-type ActionMode = "PREVIEW" | "IMPORT";
-
-const rangeOptions: Array<{ value: Range; label: string }> = [
- { value: "day", label: "Day" },
- { value: "week", label: "Week" },
- { value: "month", label: "Month" },
- { value: "year", label: "Year" },
- { value: "all", label: "All time" },
-];
-
-function extractErrorMessage(error: unknown) {
- if (error && typeof error === "object" && "message" in error) {
- const message = (error as { message?: unknown }).message;
- if (typeof message === "string" && message.length > 0) return message;
- }
- return "Request failed.";
-}
-
-function getStartOfWeek(date: Date): Date {
- const day = date.getDay();
- const diff = day === 0 ? 6 : day - 1;
- const start = new Date(date);
- start.setHours(0, 0, 0, 0);
- start.setDate(date.getDate() - diff);
- return start;
-}
-
-function getPreviousDateByRange(range: Range): Date {
- const now = new Date();
- if (range === "day")
- return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
- if (range === "week")
- return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
- if (range === "month")
- return new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
- if (range === "year")
- return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
- return new Date(0);
-}
-
-function filterByRange(
- items: StatsTransaction[],
- range: Range,
- nowDate: Date = new Date(),
-) {
- if (range === "all") return items;
-
- return items.filter((item) => {
- const date = item.createdAt;
- if (range === "day") return date.toDateString() === nowDate.toDateString();
- if (range === "week") {
- const start = getStartOfWeek(nowDate);
- return date >= start && date <= nowDate;
- }
- if (range === "month") {
- const start = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
- return date >= start && date <= nowDate;
- }
- const start = new Date(nowDate.getFullYear(), 0, 1);
- return date >= start && date <= nowDate;
- });
-}
-
-function getTotals(items: StatsTransaction[]) {
- const income = items
- .filter((item) => item.type === "INCOME")
- .reduce((acc, item) => acc + item.amount, 0);
- const outcome = items
- .filter((item) => item.type === "EXPENSE")
- .reduce((acc, item) => acc + item.amount, 0);
- const saving = income - outcome;
- const balance = saving;
-
- return { income, outcome, saving, balance };
-}
-
-function getPercentage(current: number, previous: number): number | null {
- if (previous === 0) return current === 0 ? 0 : null;
- return Math.round(((current - previous) / previous) * 100);
-}
-
-function buildChartData(items: StatsTransaction[], range: Range) {
- const map = new Map<
- string,
- { income: number; outcome: number; rawDate: Date }
- >();
- const now = new Date();
- const filtered = filterByRange(items, range, now);
-
- filtered.forEach((item) => {
- const date = new Date(item.createdAt);
- let key = "";
- let rawDate = new Date(date);
-
- if (range === "day") {
- key = `${date.getHours().toString().padStart(2, "0")}:00`;
- rawDate.setMinutes(0, 0, 0);
- } else if (range === "week") {
- key = date.toLocaleDateString("en-GB", {
- weekday: "short",
- day: "2-digit",
- month: "short",
- });
- rawDate.setHours(0, 0, 0, 0);
- } else if (range === "month") {
- const week = Math.ceil(date.getDate() / 7);
- key = `Week ${week}`;
- rawDate = new Date(
- date.getFullYear(),
- date.getMonth(),
- (week - 1) * 7 + 1,
- );
- } else if (range === "year") {
- key = date.toLocaleString("en-GB", { month: "short" });
- rawDate = new Date(date.getFullYear(), date.getMonth(), 1);
- } else {
- key = date.toLocaleString("en-GB", { month: "short", year: "numeric" });
- rawDate = new Date(date.getFullYear(), date.getMonth(), 1);
- }
-
- const group = map.get(key) || { income: 0, outcome: 0, rawDate };
- if (item.type === "INCOME") group.income += item.amount;
- else group.outcome += item.amount;
- map.set(key, group);
- });
-
- const sorted = Array.from(map.entries()).sort(
- (a, b) => a[1].rawDate.getTime() - b[1].rawDate.getTime(),
- );
- return {
- labels: sorted.map(([label]) => label),
- income: sorted.map(([, value]) => value.income),
- outcome: sorted.map(([, value]) => value.outcome),
- };
-}
-
-function getTransactionFingerprint(item: StatsTransaction) {
- return `${item.title}|${item.type}|${item.amount}|${item.currencyCode}|${item.createdAt.toISOString()}`;
-}
-
-function mergeUniqueTransactions(
- current: StatsTransaction[],
- incoming: StatsTransaction[],
-) {
- const seen = new Set(current.map(getTransactionFingerprint));
- const merged = [...current];
-
- incoming.forEach((item) => {
- const fingerprint = getTransactionFingerprint(item);
- if (seen.has(fingerprint)) return;
- seen.add(fingerprint);
- merged.push(item);
- });
-
- return merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
-}
-
-function MonobankReadonlyTransactions({
- items,
-}: {
- items: StatsTransaction[];
-}) {
- const { t } = useSafeTranslation();
- const { displayCurrency, convertAmount, formatMoney } = useCurrency();
- if (items.length === 0) {
- return ;
- }
-
- return (
-
-
- {items.map((item) => (
-
- {item.title}
-
- {formatMoney(
- convertAmount(item.amount, item.currencyCode, displayCurrency),
- displayCurrency,
- )}
-
-
-
- {item.type}
-
-
-
- {item.createdAt.toLocaleDateString("en-GB", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- })}{" "}
- {item.createdAt.toLocaleTimeString("en-GB", {
- hour: "2-digit",
- minute: "2-digit",
- })}
-
-
- ))}
-
-
- );
-}
-
-function MonobankStats({ items }: { items: StatsTransaction[] }) {
- const { t } = useSafeTranslation();
- const { displayCurrency, convertAmount, formatMoney } = useCurrency();
- const [range, setRange] = useState("all");
- const convertedItems = useMemo(
- () =>
- items.map((item) => ({
- ...item,
- amount: convertAmount(item.amount, item.currencyCode, displayCurrency),
- })),
- [items, convertAmount, displayCurrency],
- );
-
- const current = useMemo(
- () => getTotals(filterByRange(convertedItems, range)),
- [convertedItems, range],
- );
- const overall = useMemo(() => getTotals(convertedItems), [convertedItems]);
- const previous = useMemo(
- () =>
- getTotals(
- filterByRange(convertedItems, range, getPreviousDateByRange(range)),
- ),
- [convertedItems, range],
- );
- const chart = useMemo(
- () => buildChartData(convertedItems, range),
- [convertedItems, range],
- );
-
- const statsCards = [
- {
- key: "balance",
- label: t("monobank.currentBalance"),
- total: overall.balance,
- percent: 0,
- reversed: false,
- },
- {
- key: "income",
- label: t("dashboard.card.income"),
- total: current.income,
- percent: getPercentage(current.income, previous.income),
- reversed: false,
- },
- {
- key: "saving",
- label: t("dashboard.card.saving"),
- total: current.saving,
- percent: getPercentage(current.saving, previous.saving),
- reversed: false,
- },
- {
- key: "outcome",
- label: t("dashboard.card.outcome"),
- total: current.outcome,
- percent: getPercentage(current.outcome, previous.outcome),
- reversed: true,
- },
- ];
-
- return (
-
-
- {statsCards.map((card) => (
-
-
- {card.label}
-
-
-
- {formatMoney(card.total, displayCurrency)}
-
- {range !== "all" &&
- card.key !== "balance" &&
- card.percent !== null && (
- = 0
- ? "bg-(--bg-green) text-(--text-green)"
- : "bg-(--bg-red) text-(--text-red)"
- }`}
- >
- {card.percent > 0 ? "+" : ""}
- {card.percent}%
-
- )}
-
-
- ))}
-
-
-
-
-
- {t("monobank.analyticsTitle")}
-
-
-
-
-
-
-
-
- );
-}
+import { MonobankContent } from "./MonobankContent";
+import type { StatsTransaction } from "@/types/monobank-ui";
+import { useMonobankActions } from "./useMonobankActions";
export function Monobank() {
const { t } = useSafeTranslation();
@@ -469,256 +55,44 @@ export function Monobank() {
}));
}, [previewTransactions, savedMonobankData]);
- const openResultPopup = (
- type: "success" | "error",
- title: string,
- message: string,
- ) => {
- open(
- title,
- ,
- );
- };
-
- const runAfterAccountSelection = async (params: {
- token: string;
- accountId: string;
- accountCurrencyCode?: number;
- mode: ActionMode;
- }) => {
- const now = Math.floor(Date.now() / 1000);
- const from = now - (2_682_000 - 3600);
-
- try {
- const statement = await fetchMonobankData({
- token: params.token,
- accountId: params.accountId,
- accountCurrencyCode: params.accountCurrencyCode,
- from,
- to: now,
- });
-
- const mappedPreview = statement.transactions.map(
- (item: MonobankPreviewTransaction) => ({
- id: item.sourceTransactionId,
- title: item.title,
- type: item.type,
- amount: item.amount,
- currencyCode: item.currencyCode,
- createdAt: new Date(item.created_at),
- }),
- );
-
- if (params.mode === "IMPORT") {
- const importResult = await importMonobankData({
- transactions: statement.transactions,
- });
- setPreviewTransactions((current) => {
- const base = current.length > 0 ? current : transactions;
- return mergeUniqueTransactions(base, mappedPreview);
- });
- openResultPopup(
- "success",
- t("monobank.importCompleted"),
- t("monobank.importCompletedMessage", {
- loaded: statement.transactions.length,
- imported: importResult.imported,
- skipped: importResult.skipped,
- }),
- );
- return;
- }
-
- setPreviewTransactions(mappedPreview);
- openResultPopup(
- "success",
- t("monobank.previewReady"),
- t("monobank.previewReadyMessage", {
- loaded: statement.transactions.length,
- }),
- );
- } catch (error) {
- const message =
- extractErrorMessage(error) ||
- importMonobankError ||
- fetchMonobankError ||
- "Operation failed.";
- openResultPopup("error", t("monobank.requestFailed"), message);
- throw new Error(message);
- }
- };
-
- const handleFetchAccounts = async (e: React.FormEvent) => {
- e.preventDefault();
- const trimmedToken = token.trim();
-
- if (trimmedToken.length < 20) {
- setTokenError(t("monobank.tokenInvalid"));
- return;
- }
-
- setTokenError("");
-
- if (isCooldownActive) {
- setTokenError(
- t("monobank.waitBeforeRequest", { seconds: remainingSeconds }),
- );
- return;
- }
-
- try {
- startCooldown(60);
- const response = await fetchMonobankAccountsData({ token: trimmedToken });
-
- open(
- t("monobank.selectAccountTitle"),
- {
- close();
- await runAfterAccountSelection({
- token: trimmedToken,
- accountId,
- accountCurrencyCode,
- mode,
- });
- }}
- />,
- );
- } catch (error) {
- openResultPopup(
- "error",
- t("monobank.tokenCheckFailed"),
- extractErrorMessage(error),
- );
- }
- };
-
- const handleDeleteMonobankData = async () => {
- try {
- const result = await deleteMonobankData();
- setPreviewTransactions([]);
- openResultPopup(
- "success",
- t("monobank.dataRemoved"),
- t("monobank.removedMessage", { deleted: result.deleted }),
- );
- } catch (error) {
- openResultPopup(
- "error",
- t("monobank.deleteFailed"),
- extractErrorMessage(error) || deleteMonobankError || "Delete failed.",
- );
- }
- };
+ const { handleFetchAccounts, handleDeleteMonobankData } = useMonobankActions({
+ token,
+ transactions,
+ isCooldownActive,
+ remainingSeconds,
+ fetchMonobankError,
+ importMonobankError,
+ deleteMonobankError,
+ fetchMonobankAccountsData,
+ fetchMonobankData,
+ importMonobankData,
+ deleteMonobankData,
+ startCooldown,
+ setTokenError,
+ setPreviewTransactions,
+ open,
+ close,
+ t,
+ });
if (!user) {
return ;
}
return (
-
-
-
- {t("monobank.title")}
-
-
- {t("monobank.safeInfo")}
-
-
- - {t("monobank.rangeLimit")}
- - {t("monobank.cooldownLimit")}
- -
- {t("monobank.getTokenAt")}{" "}
-
- api.monobank.ua
-
- .
-
-
-
-
-
-
-
-
-
-
- {t("monobank.storageTitle")}
-
-
- {t("monobank.storageDesc")}
-
-
-
-
-
-
-
-
-
- {t("monobank.transactionsTitle")}
-
-
- {t("monobank.readonlyDesc")}
-
-
-
-
-
-
-
+ {
+ void handleDeleteMonobankData();
+ }}
+ />
);
}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankAccountCard.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountCard.tsx
new file mode 100644
index 0000000..eb9a659
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountCard.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useCurrency } from "@/hooks/useCurrency";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import type { MonobankAccount } from "@/types/monobank";
+
+function getCurrencyLabel(currencyCode?: number) {
+ if (currencyCode === 980) return "UAH";
+ if (currencyCode === 840) return "USD";
+ if (currencyCode === 978) return "EUR";
+ return currencyCode ? String(currencyCode) : "N/A";
+}
+
+function numericToAlpha(code?: number) {
+ if (code === 980) return "UAH";
+ if (code === 840) return "USD";
+ if (code === 978) return "EUR";
+ return "USD";
+}
+
+interface MonobankAccountCardProps {
+ account: MonobankAccount;
+ index: number;
+ selectedAccountId: string;
+ onSelect: (index: number) => void;
+}
+
+export function MonobankAccountCard({
+ account,
+ index,
+ selectedAccountId,
+ onSelect,
+}: MonobankAccountCardProps) {
+ const { t } = useSafeTranslation();
+ const { displayCurrency, convertAmount, formatMoney } = useCurrency();
+ const isSelected = selectedAccountId === account.id;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx
index 7aec0d7..aa795e5 100644
--- a/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankAccountSelectPopup.tsx
@@ -3,10 +3,9 @@
import { useMemo, useRef, useState } from "react";
import type { MonobankAccount } from "@/types/monobank";
import { useMonobankCooldown } from "@/hooks/useMonobankCooldown";
-import { useCurrency } from "@/hooks/useCurrency";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
-
-type ActionMode = "PREVIEW" | "IMPORT";
+import { MonobankAccountCard } from "./MonobankAccountCard";
+import type { ActionMode } from "@/types/monobank-ui";
interface MonobankAccountSelectPopupProps {
accounts: MonobankAccount[];
@@ -17,20 +16,6 @@ interface MonobankAccountSelectPopupProps {
}) => Promise;
}
-function getCurrencyLabel(currencyCode?: number) {
- if (currencyCode === 980) return "UAH";
- if (currencyCode === 840) return "USD";
- if (currencyCode === 978) return "EUR";
- return currencyCode ? String(currencyCode) : "N/A";
-}
-
-function numericToAlpha(code?: number) {
- if (code === 980) return "UAH";
- if (code === 840) return "USD";
- if (code === 978) return "EUR";
- return "USD";
-}
-
export function MonobankAccountSelectPopup({
accounts,
onContinue,
@@ -46,7 +31,6 @@ export function MonobankAccountSelectPopup({
const sliderRef = useRef(null);
const { isCooldownActive, remainingSeconds, startCooldown } =
useMonobankCooldown();
- const { displayCurrency, convertAmount, formatMoney } = useCurrency();
const selectedAccount = useMemo(
() => accounts.find((account) => account.id === selectedAccountId),
@@ -130,77 +114,15 @@ export function MonobankAccountSelectPopup({
ref={sliderRef}
className="flex snap-x snap-mandatory gap-[12px] overflow-x-auto pb-[6px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
- {accounts.map((account, index) => {
- const isSelected = selectedAccountId === account.id;
- return (
-
- );
- })}
+ {accounts.map((account, index) => (
+
+ ))}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankContent.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankContent.tsx
new file mode 100644
index 0000000..bcc541a
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankContent.tsx
@@ -0,0 +1,72 @@
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import { MonobankIntroPanel } from "./MonobankIntroPanel";
+import { MonobankTokenForm } from "./MonobankTokenForm";
+import { MonobankStoragePanel } from "./MonobankStoragePanel";
+import { MonobankReadonlyTransactions } from "./MonobankReadonlyTransactions";
+import { MonobankStats } from "./MonobankStats";
+import type { StatsTransaction } from "@/types/monobank-ui";
+
+interface MonobankContentProps {
+ token: string;
+ tokenError: string;
+ isFetchingMonobankAccounts: boolean;
+ isCooldownActive: boolean;
+ remainingSeconds: number;
+ isDeletingMonobankData: boolean;
+ transactions: StatsTransaction[];
+ onTokenChange: (value: string) => void;
+ onSubmit: (event: React.FormEvent) => void;
+ onDelete: () => void;
+}
+
+export function MonobankContent({
+ token,
+ tokenError,
+ isFetchingMonobankAccounts,
+ isCooldownActive,
+ remainingSeconds,
+ isDeletingMonobankData,
+ transactions,
+ onTokenChange,
+ onSubmit,
+ onDelete,
+}: MonobankContentProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t("monobank.transactionsTitle")}
+
+
+ {t("monobank.readonlyDesc")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankIntroPanel.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankIntroPanel.tsx
new file mode 100644
index 0000000..ddc7da7
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankIntroPanel.tsx
@@ -0,0 +1,32 @@
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+export function MonobankIntroPanel() {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+ {t("monobank.title")}
+
+
+ {t("monobank.safeInfo")}
+
+
+ - {t("monobank.rangeLimit")}
+ - {t("monobank.cooldownLimit")}
+ -
+ {t("monobank.getTokenAt")}{" "}
+
+ api.monobank.ua
+
+ .
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankReadonlyTransactions.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankReadonlyTransactions.tsx
new file mode 100644
index 0000000..a079752
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankReadonlyTransactions.tsx
@@ -0,0 +1,59 @@
+import { CustomMessage } from "@/shared/ui/Helpers";
+import { useCurrency } from "@/hooks/useCurrency";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import type { StatsTransaction } from "@/types/monobank-ui";
+
+export function MonobankReadonlyTransactions({
+ items,
+}: {
+ items: StatsTransaction[];
+}) {
+ const { t } = useSafeTranslation();
+ const { displayCurrency, convertAmount, formatMoney } = useCurrency();
+ if (items.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {items.map((item) => (
+
+ {item.title}
+
+ {formatMoney(
+ convertAmount(item.amount, item.currencyCode, displayCurrency),
+ displayCurrency,
+ )}
+
+
+
+ {item.type}
+
+
+
+ {item.createdAt.toLocaleDateString("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ })}{" "}
+ {item.createdAt.toLocaleTimeString("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankStats.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankStats.tsx
new file mode 100644
index 0000000..1c035cd
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankStats.tsx
@@ -0,0 +1,124 @@
+import { useMemo, useState } from "react";
+import {
+ Chart as ChartJS,
+ BarElement,
+ CategoryScale,
+ LinearScale,
+ Tooltip,
+ Legend,
+} from "chart.js";
+import type { Range } from "@fintrack/types";
+import { useCurrency } from "@/hooks/useCurrency";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import type { StatsTransaction } from "@/types/monobank-ui";
+import {
+ buildChartData,
+ filterByRange,
+ getPercentage,
+ getPreviousDateByRange,
+ getTotals,
+} from "@/utils/monobank";
+import { MonobankStatsChart } from "./MonobankStatsChart";
+
+ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
+
+export function MonobankStats({ items }: { items: StatsTransaction[] }) {
+ const { t } = useSafeTranslation();
+ const { displayCurrency, convertAmount, formatMoney } = useCurrency();
+ const [range, setRange] = useState("all");
+ const convertedItems = useMemo(
+ () =>
+ items.map((item) => ({
+ ...item,
+ amount: convertAmount(item.amount, item.currencyCode, displayCurrency),
+ })),
+ [items, convertAmount, displayCurrency],
+ );
+
+ const current = useMemo(
+ () => getTotals(filterByRange(convertedItems, range)),
+ [convertedItems, range],
+ );
+ const overall = useMemo(() => getTotals(convertedItems), [convertedItems]);
+ const previous = useMemo(
+ () =>
+ getTotals(
+ filterByRange(convertedItems, range, getPreviousDateByRange(range)),
+ ),
+ [convertedItems, range],
+ );
+ const chart = useMemo(
+ () => buildChartData(convertedItems, range),
+ [convertedItems, range],
+ );
+
+ const statsCards = [
+ {
+ key: "balance",
+ label: t("monobank.currentBalance"),
+ total: overall.balance,
+ percent: 0,
+ reversed: false,
+ },
+ {
+ key: "income",
+ label: t("dashboard.card.income"),
+ total: current.income,
+ percent: getPercentage(current.income, previous.income),
+ reversed: false,
+ },
+ {
+ key: "saving",
+ label: t("dashboard.card.saving"),
+ total: current.saving,
+ percent: getPercentage(current.saving, previous.saving),
+ reversed: false,
+ },
+ {
+ key: "outcome",
+ label: t("dashboard.card.outcome"),
+ total: current.outcome,
+ percent: getPercentage(current.outcome, previous.outcome),
+ reversed: true,
+ },
+ ];
+
+ return (
+
+
+ {statsCards.map((card) => (
+
+
+ {card.label}
+
+
+
+ {formatMoney(card.total, displayCurrency)}
+
+ {range !== "all" &&
+ card.key !== "balance" &&
+ card.percent !== null && (
+ = 0
+ ? "bg-(--bg-green) text-(--text-green)"
+ : "bg-(--bg-red) text-(--text-red)"
+ }`}
+ >
+ {card.percent > 0 ? "+" : ""}
+ {card.percent}%
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankStatsChart.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankStatsChart.tsx
new file mode 100644
index 0000000..5aac759
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankStatsChart.tsx
@@ -0,0 +1,106 @@
+import Select from "react-select";
+import { Bar } from "react-chartjs-2";
+import type { Range } from "@fintrack/types";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+import {
+ CHART_INCOME_COLOR,
+ CHART_OUTCOME_COLOR,
+ rangeOptions,
+} from "@/utils/monobank";
+
+interface MonobankStatsChartProps {
+ range: Range;
+ setRange: (range: Range) => void;
+ chart: {
+ labels: string[];
+ income: number[];
+ outcome: number[];
+ };
+}
+
+export function MonobankStatsChart({
+ range,
+ setRange,
+ chart,
+}: MonobankStatsChartProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+ {t("monobank.analyticsTitle")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankStoragePanel.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankStoragePanel.tsx
new file mode 100644
index 0000000..71461bf
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankStoragePanel.tsx
@@ -0,0 +1,37 @@
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface MonobankStoragePanelProps {
+ isDeletingMonobankData: boolean;
+ onDelete: () => void;
+}
+
+export function MonobankStoragePanel({
+ isDeletingMonobankData,
+ onDelete,
+}: MonobankStoragePanelProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+
+
+
+ {t("monobank.storageTitle")}
+
+
+ {t("monobank.storageDesc")}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/MonobankTokenForm.tsx b/apps/web/src/app/(protected)/monobank/_components/MonobankTokenForm.tsx
new file mode 100644
index 0000000..393ba43
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/MonobankTokenForm.tsx
@@ -0,0 +1,64 @@
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface MonobankTokenFormProps {
+ token: string;
+ tokenError: string;
+ isFetchingMonobankAccounts: boolean;
+ isCooldownActive: boolean;
+ remainingSeconds: number;
+ onTokenChange: (value: string) => void;
+ onSubmit: (event: React.FormEvent) => void;
+}
+
+export function MonobankTokenForm({
+ token,
+ tokenError,
+ isFetchingMonobankAccounts,
+ isCooldownActive,
+ remainingSeconds,
+ onTokenChange,
+ onSubmit,
+}: MonobankTokenFormProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/monobank/_components/useMonobankActions.tsx b/apps/web/src/app/(protected)/monobank/_components/useMonobankActions.tsx
new file mode 100644
index 0000000..07e7f8d
--- /dev/null
+++ b/apps/web/src/app/(protected)/monobank/_components/useMonobankActions.tsx
@@ -0,0 +1,218 @@
+import type { ActionMode, StatsTransaction } from "@/types/monobank-ui";
+import type { MonobankPreviewTransaction } from "@/types/monobank";
+import type { ReactNode, Dispatch, SetStateAction, FormEvent } from "react";
+import { MonobankAccountSelectPopup } from "./MonobankAccountSelectPopup";
+import { MonobankResultPopup } from "./MonobankResultPopup";
+import { extractErrorMessage, mergeUniqueTransactions } from "@/utils/monobank";
+
+interface UseMonobankActionsParams {
+ token: string;
+ transactions: StatsTransaction[];
+ isCooldownActive: boolean;
+ remainingSeconds: number;
+ fetchMonobankError?: string;
+ importMonobankError?: string;
+ deleteMonobankError?: string;
+ fetchMonobankAccountsData: (payload: { token: string }) => Promise<{
+ accounts: {
+ id: string;
+ type?: string;
+ currencyCode?: number;
+ cashbackType?: string;
+ balance?: number;
+ creditLimit?: number;
+ maskedPan?: string[];
+ iban?: string;
+ }[];
+ }>;
+ fetchMonobankData: (payload: {
+ token: string;
+ accountId: string;
+ accountCurrencyCode?: number;
+ from: number;
+ to: number;
+ }) => Promise<{
+ transactions: MonobankPreviewTransaction[];
+ }>;
+ importMonobankData: (payload: {
+ transactions: MonobankPreviewTransaction[];
+ }) => Promise<{
+ imported: number;
+ skipped: number;
+ }>;
+ deleteMonobankData: () => Promise<{ deleted: number }>;
+ startCooldown: (seconds: number) => void;
+ setTokenError: (value: string) => void;
+ setPreviewTransactions: Dispatch>;
+ open: (title: string, content: ReactNode) => void;
+ close: () => void;
+ t: (key: string, options?: Record) => string;
+}
+
+export function useMonobankActions({
+ token,
+ transactions,
+ isCooldownActive,
+ remainingSeconds,
+ fetchMonobankError,
+ importMonobankError,
+ deleteMonobankError,
+ fetchMonobankAccountsData,
+ fetchMonobankData,
+ importMonobankData,
+ deleteMonobankData,
+ startCooldown,
+ setTokenError,
+ setPreviewTransactions,
+ open,
+ close,
+ t,
+}: UseMonobankActionsParams) {
+ const openResultPopup = (
+ type: "success" | "error",
+ title: string,
+ message: string,
+ ) => {
+ open(
+ title,
+ ,
+ );
+ };
+
+ const runAfterAccountSelection = async (params: {
+ token: string;
+ accountId: string;
+ accountCurrencyCode?: number;
+ mode: ActionMode;
+ }) => {
+ const now = Math.floor(Date.now() / 1000);
+ const from = now - (2_682_000 - 3600);
+
+ try {
+ const statement = await fetchMonobankData({
+ token: params.token,
+ accountId: params.accountId,
+ accountCurrencyCode: params.accountCurrencyCode,
+ from,
+ to: now,
+ });
+
+ const mappedPreview = statement.transactions.map(
+ (item: MonobankPreviewTransaction) => ({
+ id: item.sourceTransactionId,
+ title: item.title,
+ type: item.type,
+ amount: item.amount,
+ currencyCode: item.currencyCode,
+ createdAt: new Date(item.created_at),
+ }),
+ );
+
+ if (params.mode === "IMPORT") {
+ const importResult = await importMonobankData({
+ transactions: statement.transactions,
+ });
+ setPreviewTransactions((current) => {
+ const base = current.length > 0 ? current : transactions;
+ return mergeUniqueTransactions(base, mappedPreview);
+ });
+ openResultPopup(
+ "success",
+ t("monobank.importCompleted"),
+ t("monobank.importCompletedMessage", {
+ loaded: statement.transactions.length,
+ imported: importResult.imported,
+ skipped: importResult.skipped,
+ }),
+ );
+ return;
+ }
+
+ setPreviewTransactions(mappedPreview);
+ openResultPopup(
+ "success",
+ t("monobank.previewReady"),
+ t("monobank.previewReadyMessage", {
+ loaded: statement.transactions.length,
+ }),
+ );
+ } catch (error) {
+ const message =
+ extractErrorMessage(error) ||
+ importMonobankError ||
+ fetchMonobankError ||
+ "Operation failed.";
+ openResultPopup("error", t("monobank.requestFailed"), message);
+ throw new Error(message);
+ }
+ };
+
+ const handleFetchAccounts = async (e: FormEvent) => {
+ e.preventDefault();
+ const trimmedToken = token.trim();
+
+ if (trimmedToken.length < 20) {
+ setTokenError(t("monobank.tokenInvalid"));
+ return;
+ }
+
+ setTokenError("");
+
+ if (isCooldownActive) {
+ setTokenError(
+ t("monobank.waitBeforeRequest", { seconds: remainingSeconds }),
+ );
+ return;
+ }
+
+ try {
+ startCooldown(60);
+ const response = await fetchMonobankAccountsData({ token: trimmedToken });
+
+ open(
+ t("monobank.selectAccountTitle"),
+ {
+ close();
+ await runAfterAccountSelection({
+ token: trimmedToken,
+ accountId,
+ accountCurrencyCode,
+ mode,
+ });
+ }}
+ />,
+ );
+ } catch (error) {
+ openResultPopup(
+ "error",
+ t("monobank.tokenCheckFailed"),
+ extractErrorMessage(error),
+ );
+ }
+ };
+
+ const handleDeleteMonobankData = async () => {
+ try {
+ const result = await deleteMonobankData();
+ setPreviewTransactions([]);
+ openResultPopup(
+ "success",
+ t("monobank.dataRemoved"),
+ t("monobank.removedMessage", { deleted: result.deleted }),
+ );
+ } catch (error) {
+ openResultPopup(
+ "error",
+ t("monobank.deleteFailed"),
+ extractErrorMessage(error) || deleteMonobankError || "Delete failed.",
+ );
+ }
+ };
+
+ return {
+ handleFetchAccounts,
+ handleDeleteMonobankData,
+ };
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx
index 5439b6f..3b5d557 100644
--- a/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx
+++ b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopup.tsx
@@ -1,43 +1,5 @@
-import { useState } from "react";
-import dynamic from "next/dynamic";
-import { toLocalDatetimeString } from "@/utils/components";
-import { usePopupStore } from "@/store/popup";
-import { CustomMessage } from "@/shared/ui/Helpers";
-import {
- useTransaction,
- useTransactionMutations,
-} from "@/hooks/useTransactions";
-import type {
- CreateTransaction,
- Location,
- UpdateTransaction,
- ResponseTransaction,
-} from "@fintrack/types";
-import type { FormTransaction } from "@/types/transaction";
-import { useNumericValidator } from "@/hooks/useNumericValidator";
-import { useCurrentLocation } from "@/hooks/useCurrentLocation";
-import { useCurrency } from "@/hooks/useCurrency";
-
-function toManualCurrencyCode(currencyCode: string): "USD" | "UAH" | "EUR" {
- if (currencyCode === "UAH") return "UAH";
- if (currencyCode === "EUR") return "EUR";
- return "USD";
-}
-
-interface MapPickerProps {
- value: Location | undefined;
- onChange: (loc: Location) => void;
-}
-
-const MapPicker = dynamic(
- () => import("./MapPicker").then((module) => module.MapPicker),
- {
- ssr: false,
- loading: () => (
-
- ),
- },
-);
+import { useTransaction } from "@/hooks/useTransactions";
+import { ChangeTransactionPopupForm } from "./ChangeTransactionPopupForm";
export function ChangeTransactionPopup({ id }: { id?: string }) {
const { data: transaction, isLoading } = useTransaction({
@@ -47,329 +9,5 @@ export function ChangeTransactionPopup({ id }: { id?: string }) {
if (id && isLoading) return Loading...
;
- return ;
-}
-
-function TransactionFormContent({
- id,
- initialData,
-}: {
- id?: string;
- initialData?: ResponseTransaction;
-}) {
- const { displayCurrency, convertAmount } = useCurrency();
- const {
- updateTx,
- isUpdating,
- createTx,
- isCreating,
- createTxErrorMessage,
- updateTxErrorMessage,
- } = useTransactionMutations();
-
- const { open, close } = usePopupStore();
- const { fetchLocation, loadingLocation, locationError, setLocationError } =
- useCurrentLocation();
- const initialConvertedAmount = initialData
- ? convertAmount(
- Number(initialData.amount),
- initialData.currencyCode ?? "USD",
- displayCurrency,
- ).toString()
- : "";
- const manualCurrencyCode = toManualCurrencyCode(displayCurrency);
- const [form, setForm] = useState({
- created_at: initialData?.created_at,
- amount: initialConvertedAmount || "",
- title: initialData?.title || "",
- currencyCode: manualCurrencyCode,
- latitude: initialData?.location?.latitude?.toString() || "",
- longitude: initialData?.location?.longitude?.toString() || "",
- type: initialData?.type || "INCOME",
- });
-
- const { formError, validateNumericInput } = useNumericValidator();
-
- const handleGetCurrentLocation = async () => {
- try {
- const result = await fetchLocation();
- setForm((prev) => ({
- ...prev,
- latitude: result.latitude.toString(),
- longitude: result.longitude.toString(),
- }));
- setLocationError(null);
- } catch (error) {
- if (error instanceof Error) {
- setLocationError(error.message);
- } else {
- setLocationError("Failed to get location.");
- }
- }
- };
- const handleNumericChange = (
- field: "amount" | "latitude" | "longitude",
- value: string,
- min?: number,
- max?: number,
- ) => {
- setForm((prev) => ({
- ...prev,
- [field]: validateNumericInput(prev[field] || "", value, min, max),
- }));
- };
-
- const handleChangeTransaction = async (
- e: React.FormEvent,
- ) => {
- try {
- e.preventDefault();
- const data: CreateTransaction = {
- title: form.title,
- type: form.type,
- created_at: form.created_at,
- updated_at: new Date(),
- amount: +form.amount,
- currencyCode: form.currencyCode ?? manualCurrencyCode,
- };
- if (form.latitude && form.longitude) {
- data.location = {
- latitude: +form.latitude,
- longitude: +form.longitude,
- };
- }
- if (id) {
- await updateTx({
- id,
- payload: data satisfies UpdateTransaction,
- });
- close();
- setTimeout(
- () =>
- open(
- "Notification",
- ,
- ),
- 300,
- );
- } else {
- await createTx(data satisfies CreateTransaction);
- close();
- setTimeout(
- () =>
- open(
- "Notification",
- ,
- ),
- 300,
- );
- }
- } catch (error) {
- open(
- "Error",
- ,
- );
- }
- };
-
- const isLocationIncomplete = !!form.latitude !== !!form.longitude;
- const isInvalid =
- !!formError || isLocationIncomplete || !form.amount || !form.title;
-
- return (
-
- );
+ return ;
}
diff --git a/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopupForm.tsx b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopupForm.tsx
new file mode 100644
index 0000000..d80d654
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/ChangeTransactionPopupForm.tsx
@@ -0,0 +1,168 @@
+import { useState } from "react";
+import { usePopupStore } from "@/store/popup";
+import { useTransactionMutations } from "@/hooks/useTransactions";
+import type { ResponseTransaction } from "@fintrack/types";
+import type { FormTransaction } from "@/types/transaction";
+import { useNumericValidator } from "@/hooks/useNumericValidator";
+import { useCurrentLocation } from "@/hooks/useCurrentLocation";
+import { useCurrency } from "@/hooks/useCurrency";
+import { TransactionBasicFields } from "./TransactionBasicFields";
+import { TransactionLocationSection } from "./TransactionLocationSection";
+import { TransactionTypeSection } from "./TransactionTypeSection";
+import { TransactionFormFooter } from "./TransactionFormFooter";
+import { submitTransaction } from "./transactionSubmit";
+
+function toManualCurrencyCode(currencyCode: string): "USD" | "UAH" | "EUR" {
+ if (currencyCode === "UAH") return "UAH";
+ if (currencyCode === "EUR") return "EUR";
+ return "USD";
+}
+
+export function ChangeTransactionPopupForm({
+ id,
+ initialData,
+}: {
+ id?: string;
+ initialData?: ResponseTransaction;
+}) {
+ const { displayCurrency, convertAmount } = useCurrency();
+ const {
+ updateTx,
+ isUpdating,
+ createTx,
+ isCreating,
+ createTxErrorMessage,
+ updateTxErrorMessage,
+ } = useTransactionMutations();
+
+ const { open, close } = usePopupStore();
+ const { fetchLocation, loadingLocation, locationError, setLocationError } =
+ useCurrentLocation();
+ const initialConvertedAmount = initialData
+ ? convertAmount(
+ Number(initialData.amount),
+ initialData.currencyCode ?? "USD",
+ displayCurrency,
+ ).toString()
+ : "";
+ const manualCurrencyCode = toManualCurrencyCode(displayCurrency);
+ const [form, setForm] = useState({
+ created_at: initialData?.created_at,
+ amount: initialConvertedAmount || "",
+ title: initialData?.title || "",
+ currencyCode: manualCurrencyCode,
+ latitude: initialData?.location?.latitude?.toString() || "",
+ longitude: initialData?.location?.longitude?.toString() || "",
+ type: initialData?.type || "INCOME",
+ });
+
+ const { formError, validateNumericInput } = useNumericValidator();
+
+ const handleGetCurrentLocation = async () => {
+ try {
+ const result = await fetchLocation();
+ setForm((prev) => ({
+ ...prev,
+ latitude: result.latitude.toString(),
+ longitude: result.longitude.toString(),
+ }));
+ setLocationError(null);
+ } catch (error) {
+ if (error instanceof Error) {
+ setLocationError(error.message);
+ } else {
+ setLocationError("Failed to get location.");
+ }
+ }
+ };
+ const handleNumericChange = (
+ field: "amount" | "latitude" | "longitude",
+ value: string,
+ min?: number,
+ max?: number,
+ ) => {
+ setForm((prev) => ({
+ ...prev,
+ [field]: validateNumericInput(prev[field] || "", value, min, max),
+ }));
+ };
+
+ const handleChangeTransaction = async (
+ e: React.FormEvent,
+ ) => {
+ e.preventDefault();
+ await submitTransaction({
+ id,
+ form,
+ manualCurrencyCode,
+ createTxErrorMessage,
+ updateTxErrorMessage,
+ updateTx,
+ createTx,
+ open,
+ close,
+ });
+ };
+
+ const isLocationIncomplete = !!form.latitude !== !!form.longitude;
+ const isInvalid =
+ !!formError || isLocationIncomplete || !form.amount || !form.title;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/TransactionBasicFields.tsx b/apps/web/src/app/(protected)/transactions/_components/TransactionBasicFields.tsx
new file mode 100644
index 0000000..25e4c52
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/TransactionBasicFields.tsx
@@ -0,0 +1,76 @@
+import { toLocalDatetimeString } from "@/utils/components";
+import type { FormTransaction } from "@/types/transaction";
+
+interface TransactionBasicFieldsProps {
+ form: FormTransaction;
+ displayCurrency: string;
+ onTitleChange: (value: string) => void;
+ onDateChange: (value: string) => void;
+ onAmountChange: (value: string) => void;
+}
+
+export function TransactionBasicFields({
+ form,
+ displayCurrency,
+ onTitleChange,
+ onDateChange,
+ onAmountChange,
+}: TransactionBasicFieldsProps) {
+ return (
+ <>
+
+
+ onTitleChange(e.target.value)}
+ className="bg-(--color-input) rounded-[10px] p-[10px] w-full border border-(--color-fixed-text) text-(--color-text) transitioned hover:border-(--color-hover)"
+ />
+
+
+
+ onDateChange(e.target.value)}
+ id="date"
+ className="bg-(--color-input) rounded-[10px] p-[10px] w-full border border-(--color-fixed-text) text-(--color-text) transitioned hover:border-(--color-hover)"
+ />
+
+
+
+ onAmountChange(e.target.value)}
+ className="bg-(--color-input) rounded-[10px] p-[10px] w-full border border-(--color-fixed-text) text-(--color-text) transitioned hover:border-(--color-hover)"
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/TransactionFormFooter.tsx b/apps/web/src/app/(protected)/transactions/_components/TransactionFormFooter.tsx
new file mode 100644
index 0000000..efd314f
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/TransactionFormFooter.tsx
@@ -0,0 +1,49 @@
+interface TransactionFormFooterProps {
+ formError?: string | null;
+ isLocationIncomplete: boolean;
+ locationError: string | null;
+ isCreating: boolean;
+ isUpdating: boolean;
+ isInvalid: boolean;
+ hasId: boolean;
+}
+
+export function TransactionFormFooter({
+ formError,
+ isLocationIncomplete,
+ locationError,
+ isCreating,
+ isUpdating,
+ isInvalid,
+ hasId,
+}: TransactionFormFooterProps) {
+ return (
+ <>
+
+ {formError && (
+
{formError}
+ )}
+ {isLocationIncomplete && (
+
+ Fill both location fields or leave them empty
+
+ )}
+ {locationError && (
+
{locationError}
+ )}
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/TransactionLocationSection.tsx b/apps/web/src/app/(protected)/transactions/_components/TransactionLocationSection.tsx
new file mode 100644
index 0000000..d7a388f
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/TransactionLocationSection.tsx
@@ -0,0 +1,103 @@
+import dynamic from "next/dynamic";
+import type { Location } from "@fintrack/types";
+import type { FormTransaction } from "@/types/transaction";
+
+interface MapPickerProps {
+ value: Location | undefined;
+ onChange: (loc: Location) => void;
+}
+
+const MapPicker = dynamic(
+ () => import("./MapPicker").then((module) => module.MapPicker),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+interface TransactionLocationSectionProps {
+ form: FormTransaction;
+ loadingLocation: boolean;
+ onLatitudeChange: (value: string) => void;
+ onLongitudeChange: (value: string) => void;
+ onGetCurrentLocation: () => void;
+ onMapChange: (loc: Location) => void;
+}
+
+export function TransactionLocationSection({
+ form,
+ loadingLocation,
+ onLatitudeChange,
+ onLongitudeChange,
+ onGetCurrentLocation,
+ onMapChange,
+}: TransactionLocationSectionProps) {
+ return (
+
+
+
+
+ onLatitudeChange(e.target.value)}
+ className="bg-(--color-input) rounded-[10px] p-[10px] w-full border border-(--color-fixed-text) text-(--color-text) transitioned hover:border-(--color-hover)"
+ />
+
+
+
+
+ onLongitudeChange(e.target.value)}
+ className="bg-(--color-input) rounded-[10px] p-[10px] w-full border border-(--color-fixed-text) text-(--color-text) transitioned hover:border-(--color-hover)"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/TransactionTypeSection.tsx b/apps/web/src/app/(protected)/transactions/_components/TransactionTypeSection.tsx
new file mode 100644
index 0000000..a437b62
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/TransactionTypeSection.tsx
@@ -0,0 +1,36 @@
+import type { FormTransaction } from "@/types/transaction";
+
+interface TransactionTypeSectionProps {
+ form: FormTransaction;
+ onTypeChange: (type: "INCOME" | "EXPENSE") => void;
+}
+
+export function TransactionTypeSection({
+ form,
+ onTypeChange,
+}: TransactionTypeSectionProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/transactions/_components/Transactions.tsx b/apps/web/src/app/(protected)/transactions/_components/Transactions.tsx
index 41268e7..4e57487 100644
--- a/apps/web/src/app/(protected)/transactions/_components/Transactions.tsx
+++ b/apps/web/src/app/(protected)/transactions/_components/Transactions.tsx
@@ -29,7 +29,12 @@ export function Transactions() {
const flatInfiniteData =
infiniteTransactions?.pages.flatMap((page) => page.data) ?? [];
- const { data: allTransactions } = useTransactionsAll({ userId: user?.id });
+ const isSearchMode = debouncedSearchQuery.trim().length > 0;
+
+ const { data: allTransactions } = useTransactionsAll({
+ userId: user?.id,
+ enabled: isSearchMode,
+ });
const cursorRef = useIntersection(() => {
if (hasNextPage && !isFetchingNextPage) {
@@ -69,7 +74,7 @@ export function Transactions() {
{/* input empty - data from infinity */}
- {user?.id && !debouncedSearchQuery && (
+ {user?.id && !isSearchMode && (
<>
{flatInfiniteData.map((item: ResponseTransaction) => (
@@ -90,7 +95,7 @@ export function Transactions() {
)}
{/* has input - data from all (filtered) */}
- {debouncedSearchQuery && (
+ {isSearchMode && (
<>
{filteredData && filteredData.data.length > 0 ? (
filteredData.data.map((item) => (
diff --git a/apps/web/src/app/(protected)/transactions/_components/transactionSubmit.tsx b/apps/web/src/app/(protected)/transactions/_components/transactionSubmit.tsx
new file mode 100644
index 0000000..13a23f6
--- /dev/null
+++ b/apps/web/src/app/(protected)/transactions/_components/transactionSubmit.tsx
@@ -0,0 +1,82 @@
+import type { CreateTransaction, UpdateTransaction } from "@fintrack/types";
+import type { ReactNode } from "react";
+import { CustomMessage } from "@/shared/ui/Helpers";
+import type { FormTransaction } from "@/types/transaction";
+
+interface SubmitTransactionParams {
+ id?: string;
+ form: FormTransaction;
+ manualCurrencyCode: "USD" | "UAH" | "EUR";
+ createTxErrorMessage?: string;
+ updateTxErrorMessage?: string;
+ updateTx: (payload: {
+ id: string;
+ payload: UpdateTransaction;
+ }) => Promise
;
+ createTx: (payload: CreateTransaction) => Promise;
+ open: (title: string, content: ReactNode) => void;
+ close: () => void;
+}
+
+export async function submitTransaction({
+ id,
+ form,
+ manualCurrencyCode,
+ createTxErrorMessage,
+ updateTxErrorMessage,
+ updateTx,
+ createTx,
+ open,
+ close,
+}: SubmitTransactionParams) {
+ try {
+ const data: CreateTransaction = {
+ title: form.title,
+ type: form.type,
+ created_at: form.created_at,
+ updated_at: new Date(),
+ amount: +form.amount,
+ currencyCode: form.currencyCode ?? manualCurrencyCode,
+ };
+ if (form.latitude && form.longitude) {
+ data.location = {
+ latitude: +form.latitude,
+ longitude: +form.longitude,
+ };
+ }
+ if (id) {
+ await updateTx({
+ id,
+ payload: data satisfies UpdateTransaction,
+ });
+ close();
+ setTimeout(
+ () =>
+ open(
+ "Notification",
+ ,
+ ),
+ 300,
+ );
+ return;
+ }
+
+ await createTx(data satisfies CreateTransaction);
+ close();
+ setTimeout(
+ () =>
+ open(
+ "Notification",
+ ,
+ ),
+ 300,
+ );
+ } catch (error) {
+ open(
+ "Error",
+ ,
+ );
+ }
+}
diff --git a/apps/web/src/app/_components/header/RegisterPopup.tsx b/apps/web/src/app/_components/header/RegisterPopup.tsx
index 7a4b988..f864d21 100644
--- a/apps/web/src/app/_components/header/RegisterPopup.tsx
+++ b/apps/web/src/app/_components/header/RegisterPopup.tsx
@@ -6,6 +6,9 @@ import { LoginPopup } from "./LoginPopup";
import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
import { queryClient } from "@/api/queryClient";
import { useRouter } from "next/navigation";
+import { RegisterPopupForm } from "./RegisterPopupForm";
+import { RegisterPopupActions } from "./RegisterPopupActions";
+import { createInitialUserLocalInfo } from "@/utils/register";
export function RegisterPopup() {
const { t } = useSafeTranslation();
@@ -19,21 +22,9 @@ export function RegisterPopup() {
const [registerSuccess, setRegisterSuccess] = useState(false);
const [passwordValidationError, setPasswordValidationError] = useState("");
- const [userLocalInfo, setUserLocalInfo] = useState({
- name: "",
- photo_url: null,
- authMethods: [
- {
- type: "EMAIL",
- email: "",
- password: "",
- },
- {
- type: "TELEGRAM",
- telegram_id: "",
- },
- ],
- });
+ const [userLocalInfo, setUserLocalInfo] = useState(
+ createInitialUserLocalInfo(),
+ );
const handleLogout = async () => {
try {
@@ -124,21 +115,7 @@ export function RegisterPopup() {
router.refresh();
close();
- setUserLocalInfo({
- name: "",
- photo_url: null,
- authMethods: [
- {
- type: "EMAIL",
- email: "",
- password: "",
- },
- {
- type: "TELEGRAM",
- telegram_id: "",
- },
- ],
- });
+ setUserLocalInfo(createInitialUserLocalInfo());
} catch {
setRegisterSuccess(false);
} finally {
@@ -156,100 +133,11 @@ export function RegisterPopup() {
}}
className="flex flex-col gap-[20px] w-full"
>
-
- setUserLocalInfo({
- ...userLocalInfo,
- name: e.target.value,
- })
- }
- className="custom-input"
- />
-
- setUserLocalInfo({
- ...userLocalInfo,
- photo_url: e.target.value,
- })
- }
- className="custom-input"
+
-
-
- m.type === "EMAIL")
- ?.email || ""
- }
- onChange={(e) => {
- const newValue = e.target.value;
- setUserLocalInfo((prev) => ({
- ...prev,
- authMethods: prev.authMethods.map((method) =>
- method.type === "EMAIL"
- ? { ...method, email: newValue }
- : method,
- ),
- }));
- }}
- className="custom-input"
- />
- m.type === "EMAIL")
- ?.password || ""
- }
- onChange={(e) => {
- const newValue = e.target.value;
- setUserLocalInfo((prev) => ({
- ...prev,
- authMethods: prev.authMethods.map((method) =>
- method.type === "EMAIL"
- ? { ...method, password: newValue }
- : method,
- ),
- }));
- }}
- className="custom-input"
- />
- m.type === "TELEGRAM")
- ?.telegram_id || ""
- }
- onChange={(e) => {
- const newValue = e.target.value;
- setUserLocalInfo((prev) => ({
- ...prev,
- authMethods: prev.authMethods.map((method) =>
- method.type === "TELEGRAM"
- ? { ...method, telegram_id: newValue }
- : method,
- ),
- }));
- }}
- className="custom-input"
- />
-
-
{passwordValidationError && (
@@ -265,25 +153,17 @@ export function RegisterPopup() {
-
- {user && (
- <>
-
-
- >
- )}
-
-
+ {
+ void handleLogout();
+ }}
+ onLogoutAll={() => {
+ void handleLogoutAll();
+ }}
+ onOpenLoginPopup={handleOpenLoginPopup}
+ />
{logoutAllError && {logoutAllError}}
);
diff --git a/apps/web/src/app/_components/header/RegisterPopupActions.tsx b/apps/web/src/app/_components/header/RegisterPopupActions.tsx
new file mode 100644
index 0000000..da5f840
--- /dev/null
+++ b/apps/web/src/app/_components/header/RegisterPopupActions.tsx
@@ -0,0 +1,42 @@
+import type { UserResponse } from "@fintrack/types";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface RegisterPopupActionsProps {
+ user?: UserResponse;
+ isLoggingOutAll: boolean;
+ onLogout: () => void;
+ onLogoutAll: () => void;
+ onOpenLoginPopup: () => void;
+}
+
+export function RegisterPopupActions({
+ user,
+ isLoggingOutAll,
+ onLogout,
+ onLogoutAll,
+ onOpenLoginPopup,
+}: RegisterPopupActionsProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+
+ {user && (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/app/_components/header/RegisterPopupForm.tsx b/apps/web/src/app/_components/header/RegisterPopupForm.tsx
new file mode 100644
index 0000000..c4c9a11
--- /dev/null
+++ b/apps/web/src/app/_components/header/RegisterPopupForm.tsx
@@ -0,0 +1,116 @@
+import type { CreateUserBody as User } from "@fintrack/types";
+import type { Dispatch, SetStateAction } from "react";
+import { useSafeTranslation } from "@/shared/i18n/useSafeTranslation";
+
+interface RegisterPopupFormProps {
+ userLocalInfo: User;
+ setUserLocalInfo: Dispatch>;
+ isRegistering: boolean;
+}
+
+export function RegisterPopupForm({
+ userLocalInfo,
+ setUserLocalInfo,
+ isRegistering,
+}: RegisterPopupFormProps) {
+ const { t } = useSafeTranslation();
+
+ return (
+ <>
+
+ setUserLocalInfo({
+ ...userLocalInfo,
+ name: e.target.value,
+ })
+ }
+ className="custom-input"
+ />
+
+ setUserLocalInfo({
+ ...userLocalInfo,
+ photo_url: e.target.value,
+ })
+ }
+ className="custom-input"
+ />
+
+
+ m.type === "EMAIL")?.email ||
+ ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setUserLocalInfo((prev) => ({
+ ...prev,
+ authMethods: prev.authMethods.map((method) =>
+ method.type === "EMAIL"
+ ? { ...method, email: newValue }
+ : method,
+ ),
+ }));
+ }}
+ className="custom-input"
+ />
+ m.type === "EMAIL")
+ ?.password || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setUserLocalInfo((prev) => ({
+ ...prev,
+ authMethods: prev.authMethods.map((method) =>
+ method.type === "EMAIL"
+ ? { ...method, password: newValue }
+ : method,
+ ),
+ }));
+ }}
+ className="custom-input"
+ />
+ m.type === "TELEGRAM")
+ ?.telegram_id || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setUserLocalInfo((prev) => ({
+ ...prev,
+ authMethods: prev.authMethods.map((method) =>
+ method.type === "TELEGRAM"
+ ? { ...method, telegram_id: newValue }
+ : method,
+ ),
+ }));
+ }}
+ className="custom-input"
+ />
+
+
+ >
+ );
+}
diff --git a/apps/web/src/hooks/useTransactions.tsx b/apps/web/src/hooks/useTransactions.tsx
index 8cb5821..776b262 100644
--- a/apps/web/src/hooks/useTransactions.tsx
+++ b/apps/web/src/hooks/useTransactions.tsx
@@ -52,16 +52,18 @@ export const useTransactionsInfinite = ({
interface UseTransactionsAllProps {
userId?: string;
source?: TransactionSource;
+ enabled?: boolean;
}
export const useTransactionsAll = ({
userId,
source,
+ enabled = true,
}: UseTransactionsAllProps) => {
return useQuery({
queryKey: ["transactions", "all", userId, source],
queryFn: ({ signal }) => getTransactions({ source }, signal),
- enabled: !!userId,
+ enabled: !!userId && enabled,
staleTime: 1000 * 60 * 5,
});
};
diff --git a/apps/web/src/shared/ui/CustomMessage.tsx b/apps/web/src/shared/ui/CustomMessage.tsx
new file mode 100644
index 0000000..e1a24a8
--- /dev/null
+++ b/apps/web/src/shared/ui/CustomMessage.tsx
@@ -0,0 +1,9 @@
+export function CustomMessage({ message }: { message: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/shared/ui/ErrorCustom.tsx b/apps/web/src/shared/ui/ErrorCustom.tsx
new file mode 100644
index 0000000..dc3a239
--- /dev/null
+++ b/apps/web/src/shared/ui/ErrorCustom.tsx
@@ -0,0 +1,9 @@
+export function ErrorCustom() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/shared/ui/Helpers.tsx b/apps/web/src/shared/ui/Helpers.tsx
index a2a6458..2c7f659 100644
--- a/apps/web/src/shared/ui/Helpers.tsx
+++ b/apps/web/src/shared/ui/Helpers.tsx
@@ -1,40 +1,4 @@
-export function Spinner() {
- return (
-
- );
-}
-
-export function ErrorCustom() {
- return (
-
- );
-}
-
-export function NoData() {
- return (
-
- );
-}
-
-export function CustomMessage({ message }: { message: string }) {
- return (
-
- );
-}
+export { Spinner } from "./Spinner";
+export { ErrorCustom } from "./ErrorCustom";
+export { NoData } from "./NoData";
+export { CustomMessage } from "./CustomMessage";
diff --git a/apps/web/src/shared/ui/NoData.tsx b/apps/web/src/shared/ui/NoData.tsx
new file mode 100644
index 0000000..026be34
--- /dev/null
+++ b/apps/web/src/shared/ui/NoData.tsx
@@ -0,0 +1,9 @@
+export function NoData() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/shared/ui/Spinner.tsx b/apps/web/src/shared/ui/Spinner.tsx
new file mode 100644
index 0000000..954da85
--- /dev/null
+++ b/apps/web/src/shared/ui/Spinner.tsx
@@ -0,0 +1,10 @@
+export function Spinner() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/types/donation.ts b/apps/web/src/types/donation.ts
index 6e21e1f..71640a4 100644
--- a/apps/web/src/types/donation.ts
+++ b/apps/web/src/types/donation.ts
@@ -1,26 +1,11 @@
-import z from "zod";
-
-export const DonationCheckoutSessionSchema = z.object({
- checkoutUrl: z.string().url(),
- checkoutSessionId: z.string(),
-});
-
-export const DonationLeaderboardItemSchema = z.object({
- userId: z.string(),
- name: z.string(),
- photoUrl: z.string().nullable(),
- totalAmountMinor: z.number().int().nonnegative(),
- currency: z.string(),
-});
-
-export const DonationLeaderboardSchema = z.object({
- items: z.array(DonationLeaderboardItemSchema),
-});
-
-export type DonationCheckoutSession = z.infer<
- typeof DonationCheckoutSessionSchema
->;
-export type DonationLeaderboardItem = z.infer<
- typeof DonationLeaderboardItemSchema
->;
-export type DonationLeaderboard = z.infer;
+export {
+ DonationCheckoutSessionSchema,
+ DonationLeaderboardItemSchema,
+ DonationLeaderboardSchema,
+} from "@fintrack/types";
+
+export type {
+ DonationCheckoutSession,
+ DonationLeaderboardItem,
+ DonationLeaderboard,
+} from "@fintrack/types";
diff --git a/apps/web/src/types/monobank-ui.ts b/apps/web/src/types/monobank-ui.ts
new file mode 100644
index 0000000..c19bf3e
--- /dev/null
+++ b/apps/web/src/types/monobank-ui.ts
@@ -0,0 +1,10 @@
+export type StatsTransaction = {
+ id: string;
+ title: string;
+ type: "INCOME" | "EXPENSE";
+ amount: number;
+ currencyCode: string;
+ createdAt: Date;
+};
+
+export type ActionMode = "PREVIEW" | "IMPORT";
diff --git a/apps/web/src/utils/admin.ts b/apps/web/src/utils/admin.ts
new file mode 100644
index 0000000..5e2f1db
--- /dev/null
+++ b/apps/web/src/utils/admin.ts
@@ -0,0 +1,24 @@
+import type { AdminUser } from "@fintrack/types";
+
+export function formatDate(value: Date | null) {
+ if (!value) return "-";
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(value);
+}
+
+export function getPreferredUserContact(user: AdminUser) {
+ const emailMethod = user.authMethods.find((item) => item.type === "EMAIL");
+ if (emailMethod?.email) return emailMethod.email;
+
+ const telegramMethod = user.authMethods.find(
+ (item) => item.type === "TELEGRAM",
+ );
+ if (telegramMethod?.telegram_id) return `tg:${telegramMethod.telegram_id}`;
+
+ const googleMethod = user.authMethods.find((item) => item.type === "GOOGLE");
+ if (googleMethod?.google_sub) return `google:${googleMethod.google_sub}`;
+
+ return "-";
+}
diff --git a/apps/web/src/utils/analytics.ts b/apps/web/src/utils/analytics.ts
new file mode 100644
index 0000000..a4a7b2f
--- /dev/null
+++ b/apps/web/src/utils/analytics.ts
@@ -0,0 +1,36 @@
+import type { AIResponseWithDiff, AiErrorCode } from "@/types/ai";
+import { toLocalDatetimeString } from "@/utils/components";
+
+export function isAiErrorCode(value: unknown): value is AiErrorCode {
+ return (
+ value === "USER_KEY_LIMIT" ||
+ value === "USER_KEY_INVALID" ||
+ value === "DEFAULT_KEY_LIMIT" ||
+ value === "ALL_KEYS_FAILED" ||
+ value === "USING_DEFAULT_KEY"
+ );
+}
+
+export function getLatestMessageIndex(history: AIResponseWithDiff[]) {
+ return history.reduce((latestIndex, message, index) => {
+ const currentTime = new Date(message.getted_at).getTime();
+ if (!Number.isFinite(currentTime)) return latestIndex;
+
+ if (latestIndex < 0) return index;
+
+ const latestTime = new Date(history[latestIndex]?.getted_at).getTime();
+ if (!Number.isFinite(latestTime) || currentTime > latestTime) {
+ return index;
+ }
+
+ return latestIndex;
+ }, -1);
+}
+
+export function formatAnalyticsDate(dateInput: Date) {
+ const date = new Date(dateInput);
+ if (Number.isFinite(date.getTime())) {
+ return toLocalDatetimeString(date, true);
+ }
+ return toLocalDatetimeString(new Date(), true);
+}
diff --git a/apps/web/src/utils/donation.ts b/apps/web/src/utils/donation.ts
new file mode 100644
index 0000000..287ce79
--- /dev/null
+++ b/apps/web/src/utils/donation.ts
@@ -0,0 +1,11 @@
+export function formatDonationAmount(amountMinor: number, currency: string) {
+ const normalized = currency?.toUpperCase() || "USD";
+ try {
+ return new Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency: normalized,
+ }).format(amountMinor / 100);
+ } catch {
+ return `${(amountMinor / 100).toFixed(2)} ${normalized}`;
+ }
+}
diff --git a/apps/web/src/utils/monobank.ts b/apps/web/src/utils/monobank.ts
new file mode 100644
index 0000000..7e25570
--- /dev/null
+++ b/apps/web/src/utils/monobank.ts
@@ -0,0 +1,163 @@
+import type { Range } from "@fintrack/types";
+import type { StatsTransaction } from "@/types/monobank-ui";
+
+export const CHART_INCOME_COLOR = "#00c07a";
+export const CHART_OUTCOME_COLOR = "#ff4d5f";
+
+export const rangeOptions: Array<{ value: Range; label: string }> = [
+ { value: "day", label: "Day" },
+ { value: "week", label: "Week" },
+ { value: "month", label: "Month" },
+ { value: "year", label: "Year" },
+ { value: "all", label: "All time" },
+];
+
+export function extractErrorMessage(error: unknown) {
+ if (error && typeof error === "object" && "message" in error) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === "string" && message.length > 0) return message;
+ }
+ return "Request failed.";
+}
+
+export function getStartOfWeek(date: Date): Date {
+ const day = date.getDay();
+ const diff = day === 0 ? 6 : day - 1;
+ const start = new Date(date);
+ start.setHours(0, 0, 0, 0);
+ start.setDate(date.getDate() - diff);
+ return start;
+}
+
+export function getPreviousDateByRange(range: Range): Date {
+ const now = new Date();
+ if (range === "day")
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
+ if (range === "week")
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7);
+ if (range === "month")
+ return new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
+ if (range === "year")
+ return new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
+ return new Date(0);
+}
+
+export function filterByRange(
+ items: StatsTransaction[],
+ range: Range,
+ nowDate: Date = new Date(),
+) {
+ if (range === "all") return items;
+
+ return items.filter((item) => {
+ const date = item.createdAt;
+ if (range === "day") return date.toDateString() === nowDate.toDateString();
+ if (range === "week") {
+ const start = getStartOfWeek(nowDate);
+ return date >= start && date <= nowDate;
+ }
+ if (range === "month") {
+ const start = new Date(nowDate.getFullYear(), nowDate.getMonth(), 1);
+ return date >= start && date <= nowDate;
+ }
+ const start = new Date(nowDate.getFullYear(), 0, 1);
+ return date >= start && date <= nowDate;
+ });
+}
+
+export function getTotals(items: StatsTransaction[]) {
+ const income = items
+ .filter((item) => item.type === "INCOME")
+ .reduce((acc, item) => acc + item.amount, 0);
+ const outcome = items
+ .filter((item) => item.type === "EXPENSE")
+ .reduce((acc, item) => acc + item.amount, 0);
+ const saving = income - outcome;
+ const balance = saving;
+
+ return { income, outcome, saving, balance };
+}
+
+export function getPercentage(
+ current: number,
+ previous: number,
+): number | null {
+ if (previous === 0) return current === 0 ? 0 : null;
+ return Math.round(((current - previous) / previous) * 100);
+}
+
+export function buildChartData(items: StatsTransaction[], range: Range) {
+ const map = new Map<
+ string,
+ { income: number; outcome: number; rawDate: Date }
+ >();
+ const now = new Date();
+ const filtered = filterByRange(items, range, now);
+
+ filtered.forEach((item) => {
+ const date = new Date(item.createdAt);
+ let key = "";
+ let rawDate = new Date(date);
+
+ if (range === "day") {
+ key = `${date.getHours().toString().padStart(2, "0")}:00`;
+ rawDate.setMinutes(0, 0, 0);
+ } else if (range === "week") {
+ key = date.toLocaleDateString("en-GB", {
+ weekday: "short",
+ day: "2-digit",
+ month: "short",
+ });
+ rawDate.setHours(0, 0, 0, 0);
+ } else if (range === "month") {
+ const week = Math.ceil(date.getDate() / 7);
+ key = `Week ${week}`;
+ rawDate = new Date(
+ date.getFullYear(),
+ date.getMonth(),
+ (week - 1) * 7 + 1,
+ );
+ } else if (range === "year") {
+ key = date.toLocaleString("en-GB", { month: "short" });
+ rawDate = new Date(date.getFullYear(), date.getMonth(), 1);
+ } else {
+ key = date.toLocaleString("en-GB", { month: "short", year: "numeric" });
+ rawDate = new Date(date.getFullYear(), date.getMonth(), 1);
+ }
+
+ const group = map.get(key) || { income: 0, outcome: 0, rawDate };
+ if (item.type === "INCOME") group.income += item.amount;
+ else group.outcome += item.amount;
+ map.set(key, group);
+ });
+
+ const sorted = Array.from(map.entries()).sort(
+ (a, b) => a[1].rawDate.getTime() - b[1].rawDate.getTime(),
+ );
+ return {
+ labels: sorted.map(([label]) => label),
+ income: sorted.map(([, value]) => value.income),
+ outcome: sorted.map(([, value]) => value.outcome),
+ };
+}
+
+function getTransactionFingerprint(item: StatsTransaction) {
+ return `${item.title}|${item.type}|${item.amount}|${item.currencyCode}|${item.createdAt.toISOString()}`;
+}
+
+export function mergeUniqueTransactions(
+ current: StatsTransaction[],
+ incoming: StatsTransaction[],
+) {
+ const seen = new Set(current.map(getTransactionFingerprint));
+ const merged = [...current];
+
+ incoming.forEach((item) => {
+ const fingerprint = getTransactionFingerprint(item);
+ if (seen.has(fingerprint)) return;
+ seen.add(fingerprint);
+ merged.push(item);
+ });
+
+ return merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+}
diff --git a/apps/web/src/utils/register.ts b/apps/web/src/utils/register.ts
new file mode 100644
index 0000000..c53f607
--- /dev/null
+++ b/apps/web/src/utils/register.ts
@@ -0,0 +1,19 @@
+import type { CreateUserBody as User } from "@fintrack/types";
+
+export function createInitialUserLocalInfo(): User {
+ return {
+ name: "",
+ photo_url: null,
+ authMethods: [
+ {
+ type: "EMAIL",
+ email: "",
+ password: "",
+ },
+ {
+ type: "TELEGRAM",
+ telegram_id: "",
+ },
+ ],
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index b6890cc..98c1c74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -170,7 +170,144 @@
"typescript-eslint": "^8.35.1"
},
"optionalDependencies": {
- "lightningcss-linux-x64-gnu": "*"
+ "@tailwindcss/oxide-linux-x64-gnu": "*",
+ "@tailwindcss/oxide-linux-x64-musl": "*",
+ "lightningcss-linux-x64-gnu": "*",
+ "lightningcss-linux-x64-musl": "*"
+ }
+ },
+ "apps/web/node_modules/@next/env": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz",
+ "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==",
+ "license": "MIT"
+ },
+ "apps/web/node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz",
+ "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz",
+ "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz",
+ "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz",
+ "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz",
+ "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz",
+ "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz",
+ "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "apps/web/node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz",
+ "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
}
},
"apps/web/node_modules/eslint-plugin-react-hooks": {
@@ -195,6 +332,87 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "apps/web/node_modules/next": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz",
+ "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.2.3",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.9.19",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.2.3",
+ "@next/swc-darwin-x64": "16.2.3",
+ "@next/swc-linux-arm64-gnu": "16.2.3",
+ "@next/swc-linux-arm64-musl": "16.2.3",
+ "@next/swc-linux-x64-gnu": "16.2.3",
+ "@next/swc-linux-x64-musl": "16.2.3",
+ "@next/swc-win32-arm64-msvc": "16.2.3",
+ "@next/swc-win32-x64-msvc": "16.2.3",
+ "sharp": "^0.34.5"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "apps/web/node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"apps/web/node_modules/typescript": {
"version": "5.8.3",
"dev": true,
@@ -2333,6 +2551,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -2772,10 +2991,11 @@
}
},
"node_modules/@next/env": {
- "version": "16.2.4",
+ "version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.4",
@@ -2789,6 +3009,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2805,6 +3026,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2816,14 +3038,12 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2835,14 +3055,12 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2854,14 +3072,12 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2873,14 +3089,12 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2897,6 +3111,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -2913,6 +3128,7 @@
"os": [
"win32"
],
+ "peer": true,
"engines": {
"node": ">= 10"
}
@@ -3390,6 +3606,22 @@
"node": ">= 20"
}
},
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
+ "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"cpu": [
@@ -8504,6 +8736,26 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"cpu": [
@@ -9169,6 +9421,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@next/env": "16.2.4",
"@swc/helpers": "0.5.15",
@@ -9264,6 +9517,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
diff --git a/packages/types/src/donation.ts b/packages/types/src/donation.ts
new file mode 100644
index 0000000..6e21e1f
--- /dev/null
+++ b/packages/types/src/donation.ts
@@ -0,0 +1,26 @@
+import z from "zod";
+
+export const DonationCheckoutSessionSchema = z.object({
+ checkoutUrl: z.string().url(),
+ checkoutSessionId: z.string(),
+});
+
+export const DonationLeaderboardItemSchema = z.object({
+ userId: z.string(),
+ name: z.string(),
+ photoUrl: z.string().nullable(),
+ totalAmountMinor: z.number().int().nonnegative(),
+ currency: z.string(),
+});
+
+export const DonationLeaderboardSchema = z.object({
+ items: z.array(DonationLeaderboardItemSchema),
+});
+
+export type DonationCheckoutSession = z.infer<
+ typeof DonationCheckoutSessionSchema
+>;
+export type DonationLeaderboardItem = z.infer<
+ typeof DonationLeaderboardItemSchema
+>;
+export type DonationLeaderboard = z.infer;
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 5117d7f..3ad2406 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -5,3 +5,4 @@ export * from "./ai.js";
export * from "./summary.js";
export * from "./monobank.js";
export * from "./admin.js";
+export * from "./donation.js";