From 6800c840420b6ba7f65bff5147af395d87947685 Mon Sep 17 00:00:00 2001 From: Daniel Akinsanya Date: Thu, 26 Feb 2026 00:47:33 +0100 Subject: [PATCH] feat(frontend): implement 'Withdraw Tokens' flow and link to Stream Details - Re-implemented StreamDetailsModal.tsx with context-aware 'Withdraw' button for recipients - Updated IncomingStreams.tsx to support 'View Details' and clickable rows - Integrated handleWithdraw logic in DashboardView utilizing Soroban contract 'withdraw' method - Implemented automatic data refresh after successful withdrawal - Cleaned up unused imports and variables across modified components closes #178 --- frontend/components/IncomingStreams.tsx | 34 ++-- .../dashboard/StreamDetailsModal.tsx | 164 ++++++++++++++++++ .../components/dashboard/dashboard-view.tsx | 60 ++++++- 3 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 frontend/components/dashboard/StreamDetailsModal.tsx diff --git a/frontend/components/IncomingStreams.tsx b/frontend/components/IncomingStreams.tsx index 603beae7..ffaf27c8 100644 --- a/frontend/components/IncomingStreams.tsx +++ b/frontend/components/IncomingStreams.tsx @@ -1,33 +1,20 @@ 'use client'; import React, { useState } from 'react'; -import toast from "react-hot-toast"; import type { Stream } from '@/lib/dashboard'; interface IncomingStreamsProps { streams: Stream[]; + onShowDetails: (stream: Stream) => void; } -const IncomingStreams: React.FC = ({ streams }) => { +const IncomingStreams: React.FC = ({ streams, onShowDetails }) => { const [filter, setFilter] = useState<'All' | 'Active' | 'Completed' | 'Paused'>('All'); const filteredStreams = filter === 'All' ? streams : streams.filter(s => s.status === filter); - const handleWithdraw = async () => { - const toastId = toast.loading("Transaction pending..."); - - try { - // Simulate async transaction (replace with real blockchain call later) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - toast.success("Withdrawal successful!", { id: toastId }); - } catch { - toast.error("Transaction failed.", { id: toastId }); - } - }; - const handleFilterChange = (e: React.ChangeEvent) => { setFilter(e.target.value as 'All' | 'Active' | 'Completed' | 'Paused'); }; @@ -69,7 +56,14 @@ const IncomingStreams: React.FC = ({ streams }) => { {filteredStreams.map((stream) => ( - + { + if ((e.target as HTMLElement).closest('button')) return; + onShowDetails(stream); + }} + > {stream.id} {stream.token} {stream.deposited} {stream.token} @@ -85,13 +79,13 @@ const IncomingStreams: React.FC = ({ streams }) => { diff --git a/frontend/components/dashboard/StreamDetailsModal.tsx b/frontend/components/dashboard/StreamDetailsModal.tsx new file mode 100644 index 00000000..1d0a7e82 --- /dev/null +++ b/frontend/components/dashboard/StreamDetailsModal.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useEffect } from "react"; +import { Button } from "@/components/ui/Button"; +import type { Stream } from "@/lib/dashboard"; + +interface StreamDetailsModalProps { + stream: Stream; + isRecipient?: boolean; + onClose: () => void; + onCancelClick: () => void; + onTopUpClick: () => void; + onWithdrawClick: () => void; +} + +export const StreamDetailsModal: React.FC = ({ + stream, + isRecipient = false, + onClose, + onCancelClick, + onTopUpClick, + onWithdrawClick, +}) => { + // Escape key support + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [onClose]); + + const progress = (stream.withdrawn / stream.deposited) * 100; + const remaining = stream.deposited - stream.withdrawn; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+

Stream Details

+

ID: {stream.id}

+
+ +
+ +
+ {/* Main Info */} +
+
+ +
+ {stream.recipient} + +
+
+ +
+
+ + + {stream.status} + +
+
+ + {stream.token} +
+
+ +
+ + +
+ {stream.withdrawn} i + of {stream.deposited} {stream.token} +
+ +
+
+
+ +

+ {remaining} {stream.token} remaining to be streamed +

+
+
+ + {/* Actions & Meta */} +
+
+ +

{stream.date}

+
+ +
+

Actions

+ + {isRecipient ? ( + + ) : ( + <> + + + + )} +
+ +
+ {isRecipient + ? "As a recipient, you can withdraw any funds that have already accrued according to the streaming rate." + : `Cancelling a stream will return any unspent funds (${remaining} ${stream.token}) to your wallet.`} +
+
+
+
+
+ ); +}; diff --git a/frontend/components/dashboard/dashboard-view.tsx b/frontend/components/dashboard/dashboard-view.tsx index e0e0082a..b21ebd48 100644 --- a/frontend/components/dashboard/dashboard-view.tsx +++ b/frontend/components/dashboard/dashboard-view.tsx @@ -28,6 +28,7 @@ import { createStream as sorobanCreateStream, topUpStream as sorobanTopUp, cancelStream as sorobanCancel, + withdrawFromStream as sorobanWithdraw, toBaseUnits, toDurationSeconds, getTokenAddress, @@ -40,6 +41,7 @@ import { } from "../stream-creation/StreamCreationWizard"; import { TopUpModal } from "../stream-creation/TopUpModal"; import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; +import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -58,7 +60,8 @@ interface SidebarItem { type ModalState = | null | { type: "topup"; stream: Stream } - | { type: "cancel"; stream: Stream }; + | { type: "cancel"; stream: Stream } + | { type: "details"; stream: Stream; isRecipient?: boolean }; interface StreamFormValues { recipient: string; @@ -197,6 +200,7 @@ function renderStreams( snapshot: DashboardSnapshot | null, onTopUp: (stream: Stream) => void, onCancel: (stream: Stream) => void, + onShowDetails: (stream: Stream) => void, ) { if (!snapshot) return null; return ( @@ -220,7 +224,14 @@ function renderStreams( {snapshot.outgoingStreams .filter((s) => s.status === "Active") .map((stream) => ( - + { + if ((e.target as HTMLElement).closest('button')) return; + onShowDetails(stream); + }} + > {stream.date} {stream.recipient} @@ -556,6 +567,25 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { } }; + const handleWithdraw = async (streamId: string) => { + const toastId = toast.loading("Withdrawing tokens…"); + try { + await sorobanWithdraw(session, { + streamId: BigInt(streamId.replace(/\D/g, "") || "0"), + }); + + setModal(null); + toast.success("Withdrawal successful!", { id: toastId }); + + if (session?.publicKey) { + fetchDashboardData(session.publicKey).then(setSnapshot); + } + } catch (err) { + toast.error(toSorobanErrorMessage(err), { id: toastId }); + throw err; + } + }; + const handleFormCreateStream = (event: React.FormEvent) => { event.preventDefault(); const hasRequiredFields = @@ -590,7 +620,10 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { if (activeTab === "incoming") { return (
- + setModal({ type: "details", stream, isRecipient: true })} + />
); } @@ -622,7 +655,12 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
{renderStats(snapshot)} {renderAnalytics(snapshot)} - {renderStreams(snapshot, (stream: Stream) => setModal({ type: "topup", stream }), (stream: Stream) => setModal({ type: "cancel", stream }))} + {renderStreams( + snapshot, + (stream: Stream) => setModal({ type: "topup", stream }), + (stream: Stream) => setModal({ type: "cancel", stream }), + (stream: Stream) => setModal({ type: "details", stream }) + )} {renderRecentActivity(snapshot)}
); @@ -911,6 +949,20 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { /> ) } + + {/* Stream Details Modal */} + { + modal?.type === "details" && ( + setModal(null)} + onCancelClick={() => setModal({ type: "cancel", stream: modal.stream })} + onTopUpClick={() => setModal({ type: "topup", stream: modal.stream })} + onWithdrawClick={() => handleWithdraw(modal.stream.id)} + /> + ) + } ); }