From 1572cf1a69c1904fe3db2ea124f8ad005b64bada Mon Sep 17 00:00:00 2001 From: mimijuwonlo-commits Date: Wed, 25 Feb 2026 22:47:28 +0100 Subject: [PATCH] feat: add stream details top-up flow and deployment configs Implements sender-gated top-up from stream details via Soroban wallet transaction. Adds vercel.json and render.yaml for production deployment setup. Closes #182 Closes #183 --- frontend/app/app/streams/[streamId]/page.tsx | 335 ++++++++++++++++++ .../components/dashboard/dashboard-view.tsx | 11 +- frontend/lib/dashboard.ts | 56 --- render.yaml | 29 ++ vercel.json | 7 + 5 files changed, 381 insertions(+), 57 deletions(-) create mode 100644 frontend/app/app/streams/[streamId]/page.tsx create mode 100644 render.yaml create mode 100644 vercel.json diff --git a/frontend/app/app/streams/[streamId]/page.tsx b/frontend/app/app/streams/[streamId]/page.tsx new file mode 100644 index 0000000..02d36d9 --- /dev/null +++ b/frontend/app/app/streams/[streamId]/page.tsx @@ -0,0 +1,335 @@ +"use client"; + +import Link from "next/link"; +import React from "react"; +import toast from "react-hot-toast"; + +import { TopUpModal } from "@/components/stream-creation/TopUpModal"; +import { Button } from "@/components/ui/Button"; +import { useWallet } from "@/context/wallet-context"; +import type { BackendStream } from "@/lib/api-types"; +import { + topUpStream as sorobanTopUp, + toBaseUnits, + toSorobanErrorMessage, +} from "@/lib/soroban"; +import { shortenPublicKey } from "@/lib/wallet"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const TOKEN_DECIMALS = 1e7; + +interface StreamDetailsPageProps { + params: { + streamId: string; + }; +} + +function toDisplayAmount(baseUnits: string): number { + const parsed = Number(baseUnits); + if (!Number.isFinite(parsed)) return 0; + return parsed / TOKEN_DECIMALS; +} + +function formatUnixTimestamp(timestamp: number): string { + const date = new Date(timestamp * 1000); + if (Number.isNaN(date.getTime())) return "N/A"; + return date.toLocaleString(); +} + +function inferTokenSymbol(tokenAddress: string): string { + const known: Record = { + USDC: process.env.NEXT_PUBLIC_USDC_ADDRESS, + XLM: process.env.NEXT_PUBLIC_XLM_ADDRESS, + EURC: process.env.NEXT_PUBLIC_EURC_ADDRESS, + }; + + const normalized = tokenAddress.toUpperCase(); + for (const [symbol, address] of Object.entries(known)) { + if (address && address.toUpperCase() === normalized) { + return symbol; + } + } + + return "TOKEN"; +} + +export default function StreamDetailsPage({ params }: StreamDetailsPageProps) { + const { session, status } = useWallet(); + + const [stream, setStream] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [showTopUpModal, setShowTopUpModal] = React.useState(false); + + const streamId = params.streamId; + const isValidStreamId = /^\d+$/.test(streamId); + + const loadStream = React.useCallback(async () => { + if (!isValidStreamId) { + setError("Stream id must be numeric."); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await fetch(`${API_BASE_URL}/streams/${streamId}`, { + cache: "no-store", + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("Stream not found."); + } + throw new Error(`Failed to load stream (${response.status}).`); + } + + const data = (await response.json()) as BackendStream; + setStream(data); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load stream."; + setError(message); + setStream(null); + } finally { + setLoading(false); + } + }, [isValidStreamId, streamId]); + + React.useEffect(() => { + void loadStream(); + }, [loadStream]); + + const depositedAmount = stream ? toDisplayAmount(stream.depositedAmount) : 0; + const withdrawnAmount = stream ? toDisplayAmount(stream.withdrawnAmount) : 0; + const remainingAmount = Math.max(depositedAmount - withdrawnAmount, 0); + const tokenSymbol = stream ? inferTokenSymbol(stream.tokenAddress) : "TOKEN"; + + const isSender = Boolean( + session && stream && session.publicKey === stream.sender, + ); + const canTopUp = + Boolean(stream?.isActive) && + status === "connected" && + Boolean(session) && + isSender; + + let topUpHelper = ""; + if (!stream?.isActive) { + topUpHelper = "Only active streams can be topped up."; + } else if (status !== "connected") { + topUpHelper = "Connect your wallet to top up this stream."; + } else if (!isSender) { + topUpHelper = "Only the stream sender can top up this stream."; + } + + const handleTopUpConfirm = async (_streamId: string, amount: string) => { + if (!stream || !session) { + throw new Error("Wallet is not connected."); + } + + const toastId = toast.loading("Submitting top up transaction..."); + + try { + const amountInBaseUnits = toBaseUnits(amount); + + await sorobanTopUp(session, { + streamId: BigInt(stream.streamId), + amount: amountInBaseUnits, + }); + + setStream((previous) => { + if (!previous) return previous; + + let nextDepositedAmount = previous.depositedAmount; + try { + nextDepositedAmount = ( + BigInt(previous.depositedAmount) + amountInBaseUnits + ).toString(); + } catch { + nextDepositedAmount = previous.depositedAmount; + } + + return { + ...previous, + depositedAmount: nextDepositedAmount, + lastUpdateTime: Math.floor(Date.now() / 1000), + }; + }); + + setShowTopUpModal(false); + toast.success("Top up transaction submitted.", { id: toastId }); + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + + if (loading) { + return ( +
+
+
+

Loading stream...

+

Fetching stream details and latest balances.

+
+
+ ); + } + + if (error || !stream) { + return ( +
+
+

Stream Details

+

Unable to load stream

+

{error ?? "The requested stream could not be loaded."}

+
+ + Back to Dashboard + + +
+
+
+ ); + } + + return ( +
+
+
+
+

Stream Details

+

Stream #{stream.streamId}

+

+ Created {new Date(stream.createdAt).toLocaleString()} +

+
+ +
+ + Back + + +
+
+ + {topUpHelper ?

{topUpHelper}

: null} + +
+
+

Overview

+ {stream.isActive ? "Active" : "Inactive"} +
+ +
+
+

Deposited

+

+ {depositedAmount.toFixed(2)} {tokenSymbol} +

+ Total funded to the stream. +
+
+

Withdrawn

+

+ {withdrawnAmount.toFixed(2)} {tokenSymbol} +

+ Amount claimed by recipient. +
+
+

Remaining

+

+ {remainingAmount.toFixed(2)} {tokenSymbol} +

+ Estimated balance still in stream. +
+
+
+ +
+
+

Participants

+ Sender and recipient wallets +
+
+
+ Sender + {shortenPublicKey(stream.sender)} +
+
+ Recipient + {shortenPublicKey(stream.recipient)} +
+
+ Token Contract + {shortenPublicKey(stream.tokenAddress)} +
+
+ Last Update + {formatUnixTimestamp(stream.lastUpdateTime)} +
+
+
+ +
+
+

Indexed Events

+ {stream.events?.length ?? 0} events +
+ + {!stream.events || stream.events.length === 0 ? ( +
+

No indexed events yet for this stream.

+
+ ) : ( +
+ + + + + + + + + + + {stream.events.map((event) => ( + + + + + + + ))} + +
TypeAmountLedgerTimestamp
{event.eventType} + {event.amount + ? `${toDisplayAmount(event.amount).toFixed(2)} ${tokenSymbol}` + : "-"} + {event.ledgerSequence}{formatUnixTimestamp(event.timestamp)}
+
+ )} +
+
+ + {showTopUpModal ? ( + setShowTopUpModal(false)} + /> + ) : null} +
+ ); +} diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx index e0e0082..d065cff 100644 --- a/frontend/components/dashboard/dashboard-view.tsx +++ b/frontend/components/dashboard/dashboard-view.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import Link from "next/link"; import toast from "react-hot-toast"; /** @@ -233,6 +234,14 @@ function renderStreams(
+ {/^\d+$/.test(stream.id) ? ( + + Details + + ) : null}
); } diff --git a/frontend/lib/dashboard.ts b/frontend/lib/dashboard.ts index ac86e92..ea0e356 100644 --- a/frontend/lib/dashboard.ts +++ b/frontend/lib/dashboard.ts @@ -39,62 +39,6 @@ export interface DashboardAnalyticsMetric { unavailableText: string; } -const MOCK_STATS_BY_WALLET: Record = { - freighter: { - totalSent: 12850, - totalReceived: 4720, - totalValueLocked: 32140, - activeStreamsCount: 2, - streams: [ - { - id: "stream-1", - date: "2023-10-25", - recipient: "G...ABCD", - amount: 500, - token: "USDC", - status: "Active", - deposited: 500, - withdrawn: 100, - }, - { - id: "stream-2", - date: "2023-10-26", - recipient: "G...EFGH", - amount: 1200, - token: "XLM", - status: "Active", - deposited: 1200, - withdrawn: 300, - }, - ], - recentActivity: [ - { - id: "act-1", - title: "Design Retainer", - description: "Outgoing stream settled", - amount: 250, - direction: "sent", - timestamp: "2026-02-19T13:10:00.000Z", - }, - { - id: "act-2", - title: "Community Grant", - description: "Incoming stream payout", - amount: 420, - direction: "received", - timestamp: "2026-02-18T17:45:00.000Z", - }, - { - id: "act-3", - title: "Developer Subscription", - description: "Outgoing recurring payment", - amount: 85, - direction: "sent", - timestamp: "2026-02-18T09:15:00.000Z", - }, - ], - }, -}; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; /** diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..90b6758 --- /dev/null +++ b/render.yaml @@ -0,0 +1,29 @@ +services: + - type: web + name: flowfi-backend + env: node + rootDir: backend + autoDeploy: true + buildCommand: npm install && npm run prisma:generate && npm run build + startCommand: npm run prisma:deploy && npm run start + healthCheckPath: /health + envVars: + - key: NODE_VERSION + value: 20 + - key: NODE_ENV + value: production + - key: PORT + value: "3001" + - key: DATABASE_URL + fromDatabase: + name: flowfi-postgres + property: connectionString + - key: SANDBOX_MODE_ENABLED + value: "false" + - key: LOG_LEVEL + value: info + +databases: + - name: flowfi-postgres + databaseName: flowfi + user: flowfi diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..83e51d8 --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "installCommand": "npm install", + "buildCommand": "npm run build --workspace=frontend", + "devCommand": "npm run dev --workspace=frontend" +}