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 && ( + + )} +
+ + {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" - } } } }