Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions components/dashboard/PositionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<Card data-testid="position-card">
Expand All @@ -45,6 +48,14 @@ export function PositionCard({ position }: PositionCardProps) {
</p>
</div>
<div className="flex items-center gap-2">
{isKeyHolding && <HoldingActionsMenu position={position} />}
{canTopUp && (
<TopUpModal
invoiceId={position.invoice_id}
currentCommittedAmount={position.committed_amount}
remainingCapacity={position.remaining_capacity ?? 0}
/>
)}
{position.status === "active" && (
<PositionTransferModal position={position} />
)}
Expand Down
39 changes: 39 additions & 0 deletions components/dashboard/__tests__/PositionCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PositionCard
position={makePosition({
status: "active",
remaining_capacity: 1000,
})}
/>
);

expect(screen.getByTestId("top-up-button")).toBeInTheDocument();
});

it("does not render Top Up button for settled positions", () => {
render(
<PositionCard
position={makePosition({
status: "settled",
remaining_capacity: 1000,
})}
/>
);

expect(screen.queryByTestId("top-up-button")).not.toBeInTheDocument();
});

it("does not render Top Up button when remaining capacity is zero", () => {
render(
<PositionCard
position={makePosition({
status: "active",
remaining_capacity: 0,
})}
/>
);

expect(screen.queryByTestId("top-up-button")).not.toBeInTheDocument();
});
});
154 changes: 154 additions & 0 deletions components/invoices/TopUpModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
data-testid="top-up-button"
>
Top Up
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-4">
<div className="space-y-2">
<h3 className="font-semibold">Top Up Investment</h3>
<p className="text-sm text-muted-foreground">
Increase your position in this invoice
</p>
</div>

<div className="space-y-1.5">
<Label>Current committed amount</Label>
<p className="text-sm font-medium">
{currentCommittedAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} XLM
</p>
</div>

<div className="space-y-1.5">
<Label htmlFor="top-up-amount">Additional amount (XLM)</Label>
<Input
id="top-up-amount"
inputMode="decimal"
placeholder={`0 - ${remainingCapacity.toLocaleString()}`}
value={additionalAmount}
aria-invalid={error !== null}
aria-describedby={error ? "top-up-amount-error" : undefined}
className={cn(error && "border-destructive focus-visible:ring-destructive")}
onChange={(e) => handleAmountChange(e.target.value)}
/>
{error && (
<p id="top-up-amount-error" role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>

{additionalAmount && !error && (
<div className="space-y-1.5">
<Label>New total position</Label>
<p className="text-sm font-medium">
{newTotal.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} XLM
</p>
</div>
)}

<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={() => setIsOpen(false)}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleTopUp}
disabled={!!error || !additionalAmount || topUpMutation.isPending}
className="flex-1"
>
{topUpMutation.isPending ? "Processing..." : "Confirm Top Up"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
14 changes: 10 additions & 4 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,16 @@ export async function distributeDividend(
token?: string
): Promise<DividendDistributionResult> {
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"
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface InvestmentPosition {
key_title?: string;
quantity?: number;
lockup_expires_at?: string | null;
remaining_capacity?: number;
}

export interface PortfolioSummary {
Expand Down
Loading
Loading