diff --git a/components/dashboard/PositionCard.tsx b/components/dashboard/PositionCard.tsx
index 9d7e428..7b8d342 100644
--- a/components/dashboard/PositionCard.tsx
+++ b/components/dashboard/PositionCard.tsx
@@ -2,6 +2,8 @@
import { Card, CardContent } from "@/components/ui/card";
import { InvoiceStatusBadge } from "@/components/invoices/InvoiceStatusBadge";
+import { HoldingActionsMenu } from "@/components/dashboard/HoldingActionsMenu";
+import { TopUpModal } from "@/components/invoices/TopUpModal";
import { KeyTransferModal } from "@/components/dashboard/KeyTransferModal";
import { PositionTransferModal } from "@/components/dashboard/PositionTransferModal";
import { BurnKeyModal } from "@/components/keys/BurnKeyModal";
@@ -29,6 +31,7 @@ interface PositionCardProps {
export function PositionCard({ position }: PositionCardProps) {
const shareDisplay = formatSharePercent(position.share_percent);
const isKeyHolding = Boolean(position.key_id);
+ const canTopUp = position.status === "active" && (position.remaining_capacity ?? 0) > 0;
return (
@@ -45,6 +48,14 @@ export function PositionCard({ position }: PositionCardProps) {
+ {isKeyHolding &&
}
+ {canTopUp && (
+
+ )}
{position.status === "active" && (
)}
diff --git a/components/dashboard/__tests__/PositionCard.test.tsx b/components/dashboard/__tests__/PositionCard.test.tsx
index 3513859..3924480 100644
--- a/components/dashboard/__tests__/PositionCard.test.tsx
+++ b/components/dashboard/__tests__/PositionCard.test.tsx
@@ -78,4 +78,43 @@ describe("PositionCard", () => {
expect(screen.getByTestId("position-share")).toHaveTextContent("12.35%");
});
+
+ it("renders Top Up button for active positions with remaining capacity", () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId("top-up-button")).toBeInTheDocument();
+ });
+
+ it("does not render Top Up button for settled positions", () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId("top-up-button")).not.toBeInTheDocument();
+ });
+
+ it("does not render Top Up button when remaining capacity is zero", () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId("top-up-button")).not.toBeInTheDocument();
+ });
});
diff --git a/components/invoices/TopUpModal.tsx b/components/invoices/TopUpModal.tsx
new file mode 100644
index 0000000..2204cee
--- /dev/null
+++ b/components/invoices/TopUpModal.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useTopUpMutation } from "@/hooks/useInvestments";
+import { cn } from "@/lib/utils";
+
+interface TopUpModalProps {
+ invoiceId: string;
+ currentCommittedAmount: number;
+ remainingCapacity: number;
+ onSuccess?: () => void;
+}
+
+export function TopUpModal({
+ invoiceId,
+ currentCommittedAmount,
+ remainingCapacity,
+ onSuccess,
+}: TopUpModalProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [additionalAmount, setAdditionalAmount] = useState("");
+ const [error, setError] = useState
(null);
+ const topUpMutation = useTopUpMutation();
+
+ const validateAmount = (value: string): string | null => {
+ if (value.trim() === "") {
+ return null;
+ }
+
+ const amount = Number(value);
+
+ if (Number.isNaN(amount) || amount <= 0) {
+ return "Please enter a valid positive amount";
+ }
+
+ if (amount > remainingCapacity) {
+ return "Exceeds remaining capacity";
+ }
+
+ return null;
+ };
+
+ const handleAmountChange = (value: string) => {
+ setAdditionalAmount(value);
+ setError(validateAmount(value));
+ };
+
+ const handleTopUp = async () => {
+ const validationError = validateAmount(additionalAmount);
+ if (validationError) {
+ setError(validationError);
+ return;
+ }
+
+ const amount = Number(additionalAmount);
+ await topUpMutation.mutateAsync({ invoiceId, amount });
+ setIsOpen(false);
+ setAdditionalAmount("");
+ setError(null);
+ onSuccess?.();
+ };
+
+ const newTotal = currentCommittedAmount + Number(additionalAmount || 0);
+
+ return (
+
+
+
+
+
+
+
+
Top Up Investment
+
+ Increase your position in this invoice
+
+
+
+
+
+
+ {currentCommittedAmount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })} XLM
+
+
+
+
+
+
handleAmountChange(e.target.value)}
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {additionalAmount && !error && (
+
+
+
+ {newTotal.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })} XLM
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/lib/api/index.ts b/lib/api/index.ts
index d38cf53..24f9e7b 100644
--- a/lib/api/index.ts
+++ b/lib/api/index.ts
@@ -1006,6 +1006,16 @@ export async function distributeDividend(
token?: string
): Promise {
const res = await fetch(`${API_BASE}/keys/${keyId}/distribute-dividend`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...authHeaders(token),
+ },
+ body: JSON.stringify({ amount, wallet: walletAddress }),
+ });
+ if (!res.ok) throw new Error("Dividend distribution failed");
+ return normalizeDividendResult(await res.json(), amount);
+}
export type WalletActivityType =
| "buy"
@@ -1284,10 +1294,6 @@ export async function approveKeyPause(
"Content-Type": "application/json",
...authHeaders(token),
},
- body: JSON.stringify({ amount, wallet: walletAddress }),
- });
- if (!res.ok) throw new Error("Dividend distribution failed");
- return normalizeDividendResult(await res.json(), amount);
});
if (!res.ok) {
throw new Error(await readErrorMessage(res, "Failed to approve pause"));
diff --git a/lib/portfolio.ts b/lib/portfolio.ts
index af99970..d402b5c 100644
--- a/lib/portfolio.ts
+++ b/lib/portfolio.ts
@@ -10,6 +10,7 @@ export interface InvestmentPosition {
key_title?: string;
quantity?: number;
lockup_expires_at?: string | null;
+ remaining_capacity?: number;
}
export interface PortfolioSummary {
diff --git a/package-lock.json b/package-lock.json
index 1af5b1f..67a6eb1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -251,7 +251,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -300,7 +299,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -317,6 +315,16 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -2359,6 +2367,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -2771,7 +2802,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2965,7 +2995,6 @@
"version": "22.19.15",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2974,7 +3003,6 @@
"version": "19.2.14",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2983,7 +3011,6 @@
"version": "19.2.3",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3027,7 +3054,6 @@
"version": "8.57.0",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@@ -3634,7 +3660,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4770,7 +4795,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4926,7 +4950,6 @@
"version": "2.32.0",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6206,7 +6229,6 @@
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
@@ -7685,7 +7707,6 @@
"node_modules/react": {
"version": "19.2.4",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7693,7 +7714,6 @@
"node_modules/react-dom": {
"version": "19.2.4",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -7704,7 +7724,6 @@
"node_modules/react-hook-form": {
"version": "7.71.2",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -9130,7 +9149,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -9356,7 +9374,6 @@
"version": "5.9.3",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9513,7 +9530,6 @@
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.5",
@@ -9961,111 +9977,6 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
- },
- "node_modules/@next/swc-darwin-arm64": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
- "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-darwin-x64": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
- "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
- "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
- "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
- "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-musl": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
- "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.5.12",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
- "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
}
}
}