Skip to content
Open
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
1 change: 1 addition & 0 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
streamId={modal.stream.id}
token={modal.stream.token}
currentDeposited={modal.stream.deposited}
ratePerSecond={modal.stream.ratePerSecond}
onConfirm={handleTopUpConfirm}
onClose={() => setModal(null)}
/>
Expand Down
110 changes: 110 additions & 0 deletions frontend/src/components/stream-creation/TopUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,69 @@ import { useModalDialog } from "@/hooks/useModalDialog";
import toast from "react-hot-toast";
import { transactionSuccessToast } from "@/lib/transaction-feedback";

export const SECONDS_PER_DAY = 86_400;
export const RUNWAY_PRESET_DAYS = [7, 30, 90] as const;

export function calculateRunwayPresetAmount(
ratePerSecond: number,
days: number,
decimals = 7,
): string {
if (
!Number.isFinite(ratePerSecond) ||
ratePerSecond <= 0 ||
!Number.isFinite(days) ||
days <= 0
) {
return "0";
}
const raw = ratePerSecond * SECONDS_PER_DAY * days;
return raw.toFixed(decimals).replace(/\.?0+$/, "");
}

export function calculateProjectedEndDate(
topUpAmount: number,
ratePerSecond: number,
currentEndTime?: number | Date,
now: Date = new Date(),
): Date | null {
if (
!Number.isFinite(topUpAmount) ||
topUpAmount <= 0 ||
!Number.isFinite(ratePerSecond) ||
ratePerSecond <= 0
) {
return null;
}
let baseMs: number;
if (currentEndTime instanceof Date) {
baseMs = currentEndTime.getTime();
} else if (typeof currentEndTime === "number" && Number.isFinite(currentEndTime) && currentEndTime > 0) {
baseMs = currentEndTime < 1e12 ? currentEndTime * 1000 : currentEndTime;
} else {
baseMs = now.getTime();
}
const additionalMs = (topUpAmount / ratePerSecond) * 1000;
return new Date(baseMs + additionalMs);
}

export function formatProjectedEndDate(date: Date): string {
const isoDate = date.toISOString().split("T")[0] ?? "";
const readable = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
return `${readable} (${isoDate})`;
}

interface TopUpModalProps {
streamId: string;
token: string;
currentDeposited: number;
ratePerSecond?: number;
currentEndTime?: number | Date;
onConfirm: (streamId: string, amount: string) => Promise<void>;
onClose: () => void;
}
Expand All @@ -26,6 +85,8 @@ export const TopUpModal: React.FC<TopUpModalProps> = ({
streamId,
token,
currentDeposited,
ratePerSecond,
currentEndTime,
onConfirm,
onClose,
}) => {
Expand All @@ -43,6 +104,22 @@ export const TopUpModal: React.FC<TopUpModalProps> = ({
// Token decimals - using 7 as default (Stellar standard)
const TOKEN_DECIMALS = 7;

const hasValidRate =
typeof ratePerSecond === "number" &&
Number.isFinite(ratePerSecond) &&
ratePerSecond > 0;

const handlePresetClick = (days: number) => {
if (!hasValidRate || ratePerSecond === undefined) return;
const presetAmount = calculateRunwayPresetAmount(
ratePerSecond,
days,
TOKEN_DECIMALS,
);
setAmount(presetAmount);
if (error) setError(null);
};

const validate = (): boolean => {
const validationError = validateAmountInput(amount, TOKEN_DECIMALS);
if (validationError) {
Expand Down Expand Up @@ -73,6 +150,10 @@ export const TopUpModal: React.FC<TopUpModalProps> = ({
const newTotal = !isNaN(parsedAmount) && parsedAmount > 0
? currentDeposited + parsedAmount
: null;
const projectedEndDate =
newTotal !== null && hasValidRate && ratePerSecond !== undefined
? calculateProjectedEndDate(parsedAmount, ratePerSecond, currentEndTime)
: null;

return (
<div
Expand Down Expand Up @@ -154,6 +235,24 @@ export const TopUpModal: React.FC<TopUpModalProps> = ({
</span>
</div>

<div
className="mt-3 flex flex-wrap items-center gap-2"
role="group"
aria-label="Quick runway presets"
>
{RUNWAY_PRESET_DAYS.map((days) => (
<button
key={days}
type="button"
disabled={isSubmitting || !hasValidRate}
onClick={() => handlePresetClick(days)}
className="px-3 py-1 rounded-full text-xs font-medium border border-glass-border bg-white/5 text-slate-300 hover:border-accent hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
+{days} Days
</button>
))}
</div>

{error && (
<p id="topup-error" className="mt-2 text-sm text-red-400 flex items-center gap-1" role="alert">
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand All @@ -173,6 +272,17 @@ export const TopUpModal: React.FC<TopUpModalProps> = ({
{newTotal.toFixed(2)} {token}
</strong>
</p>
{projectedEndDate !== null && (
<p
className="text-slate-300 mt-1"
data-testid="projected-end-date"
>
Projected end date:{" "}
<strong className="text-accent">
{formatProjectedEndDate(projectedEndDate)}
</strong>
</p>
)}
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ vi.mock("@/lib/transaction-feedback", () => ({

import toast from "react-hot-toast";
import { transactionSuccessToast } from "@/lib/transaction-feedback";
import { TopUpModal } from "../TopUpModal";
import {
TopUpModal,
calculateRunwayPresetAmount,
calculateProjectedEndDate,
} from "../TopUpModal";

describe("TopUpModal", () => {
const baseProps = {
streamId: "stream-42",
token: "USDC",
currentDeposited: 500,
ratePerSecond: 0.01,
onConfirm: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
};
Expand Down Expand Up @@ -87,6 +92,53 @@ describe("TopUpModal", () => {
});
});

describe("quick runway presets and projected end date", () => {
it("populates exact deposit amounts when clicking +7 Days, +30 Days, or +90 Days", () => {
// ratePerSecond = 0.01 => 864 tokens/day
render(<TopUpModal {...baseProps} ratePerSecond={0.01} />);

fireEvent.click(screen.getByRole("button", { name: /\+7 Days/i }));
expect(getAmountInput().value).toBe("6048");
expect(screen.getByText(/6548\.00 USDC/)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /\+30 Days/i }));
expect(getAmountInput().value).toBe("25920");
expect(screen.getByText(/26420\.00 USDC/)).toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /\+90 Days/i }));
expect(getAmountInput().value).toBe("77760");
expect(screen.getByText(/78260\.00 USDC/)).toBeInTheDocument();
});

it("displays the new projected end date below the input when an amount is entered", () => {
const fixedStart = new Date("2026-05-01T00:00:00.000Z");
render(
<TopUpModal
{...baseProps}
ratePerSecond={0.01}
currentEndTime={fixedStart}
/>,
);

expect(screen.queryByTestId("projected-end-date")).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: /\+30 Days/i }));

const projected = screen.getByTestId("projected-end-date");
expect(projected).toBeInTheDocument();
expect(projected).toHaveTextContent("2026-05-31");
expect(projected).toHaveTextContent("May 31, 2026");
});

it("handles non-positive or invalid ratePerSecond and days safely in helpers", () => {
expect(calculateRunwayPresetAmount(0, 7)).toBe("0");
expect(calculateRunwayPresetAmount(-1, 7)).toBe("0");
expect(calculateRunwayPresetAmount(0.01, 0)).toBe("0");
expect(calculateProjectedEndDate(0, 0.01)).toBeNull();
expect(calculateProjectedEndDate(100, 0)).toBeNull();
});
});

describe("submission error handling", () => {
it("shows toast.error and re-enables the form when onConfirm rejects", async () => {
const user = userEvent.setup();
Expand Down
Loading