From 1688a7f28394efd33f7587a4d7c9f8753f8362d6 Mon Sep 17 00:00:00 2001 From: Abdullah Azmi Date: Mon, 19 Oct 2026 02:49:38 +0000 Subject: [PATCH] feat(frontend): add quick runway preset buttons and projected end date to TopUpModal Add +7 Days, +30 Days, and +90 Days quick-fill preset pill buttons to TopUpModal that compute the required token deposit from ratePerSecond (ratePerSecond * 86400 * days) and display the new projected end date below the input when a valid top-up amount is entered. Fixes #1635 --- .../components/dashboard/dashboard-view.tsx | 1 + .../components/stream-creation/TopUpModal.tsx | 110 ++++++++++++++++++ .../__tests__/TopUpModal.test.tsx | 54 ++++++++- 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 511c6586..4ba9eba0 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -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)} /> diff --git a/frontend/src/components/stream-creation/TopUpModal.tsx b/frontend/src/components/stream-creation/TopUpModal.tsx index 4aa404aa..a7211f53 100644 --- a/frontend/src/components/stream-creation/TopUpModal.tsx +++ b/frontend/src/components/stream-creation/TopUpModal.tsx @@ -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; onClose: () => void; } @@ -26,6 +85,8 @@ export const TopUpModal: React.FC = ({ streamId, token, currentDeposited, + ratePerSecond, + currentEndTime, onConfirm, onClose, }) => { @@ -43,6 +104,22 @@ export const TopUpModal: React.FC = ({ // 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) { @@ -73,6 +150,10 @@ export const TopUpModal: React.FC = ({ const newTotal = !isNaN(parsedAmount) && parsedAmount > 0 ? currentDeposited + parsedAmount : null; + const projectedEndDate = + newTotal !== null && hasValidRate && ratePerSecond !== undefined + ? calculateProjectedEndDate(parsedAmount, ratePerSecond, currentEndTime) + : null; return (
= ({
+
+ {RUNWAY_PRESET_DAYS.map((days) => ( + + ))} +
+ {error && ( + {projectedEndDate !== null && ( +

+ Projected end date:{" "} + + {formatProjectedEndDate(projectedEndDate)} + +

+ )} )} diff --git a/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx b/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx index 044fa792..83906f2f 100644 --- a/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx +++ b/frontend/src/components/stream-creation/__tests__/TopUpModal.test.tsx @@ -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(), }; @@ -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(); + + 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( + , + ); + + 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();