Skip to content

Commit 4b641a5

Browse files
committed
feat(frontend): implement 'Cancel Stream' flow and Stream Details view
- Created StreamDetailsModal.tsx to display stream progress and metadata - Integrated StreamDetailsModal into DashboardView with clickable stream rows - Added 'Cancel Stream' button to Details view with confirmation flow - Integrated with Soroban contract 'cancel_stream' functionality - Implemented optimistic UI updates and toast notifications for transactions - Verified contract logic for sender refund of unspent funds closes #180
1 parent 61d1f86 commit 4b641a5

2 files changed

Lines changed: 176 additions & 3 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"use client";
2+
3+
import React, { useEffect, useState } from "react";
4+
import { Button } from "@/components/ui/Button";
5+
import type { Stream } from "@/lib/dashboard";
6+
import { shortenPublicKey } from "@/lib/wallet";
7+
8+
interface StreamDetailsModalProps {
9+
stream: Stream;
10+
onClose: () => void;
11+
onCancelClick: () => void;
12+
onTopUpClick: () => void;
13+
}
14+
15+
export const StreamDetailsModal: React.FC<StreamDetailsModalProps> = ({
16+
stream,
17+
onClose,
18+
onCancelClick,
19+
onTopUpClick,
20+
}) => {
21+
// Escape key support
22+
useEffect(() => {
23+
const handleEscape = (e: KeyboardEvent) => {
24+
if (e.key === "Escape") onClose();
25+
};
26+
window.addEventListener("keydown", handleEscape);
27+
return () => window.removeEventListener("keydown", handleEscape);
28+
}, [onClose]);
29+
30+
const progress = (stream.withdrawn / stream.deposited) * 100;
31+
const remaining = stream.deposited - stream.withdrawn;
32+
33+
return (
34+
<div
35+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
36+
onClick={(e) => {
37+
if (e.target === e.currentTarget) onClose();
38+
}}
39+
>
40+
<div className="glass-card relative w-full max-w-2xl mx-4 rounded-3xl border border-glass-border p-8 shadow-2xl animate-in fade-in zoom-in-95">
41+
{/* Header */}
42+
<div className="flex items-center justify-between mb-8">
43+
<div>
44+
<h2 className="text-2xl font-black tracking-tight">Stream Details</h2>
45+
<p className="text-sm text-slate-400 font-mono">ID: {stream.id}</p>
46+
</div>
47+
<button
48+
onClick={onClose}
49+
className="p-2 rounded-full hover:bg-white/10 text-slate-400 transition-colors"
50+
>
51+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
52+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
53+
</svg>
54+
</button>
55+
</div>
56+
57+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
58+
{/* Main Info */}
59+
<div className="space-y-6">
60+
<div className="p-4 rounded-2xl bg-white/5 border border-glass-border">
61+
<label className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-1 block">Recipient</label>
62+
<div className="flex items-center gap-2">
63+
<code className="text-sm text-accent truncate">{stream.recipient}</code>
64+
<button
65+
onClick={() => navigator.clipboard.writeText(stream.recipient)}
66+
className="text-slate-500 hover:text-accent transition-colors"
67+
>
68+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
69+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
70+
</svg>
71+
</button>
72+
</div>
73+
</div>
74+
75+
<div className="grid grid-cols-2 gap-4">
76+
<div className="p-4 rounded-2xl bg-white/5 border border-glass-border">
77+
<label className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-1 block">Status</label>
78+
<span className={`inline-flex px-2.5 py-0.5 rounded-full text-xs font-bold ${stream.status === 'Active' ? 'bg-green-500/20 text-green-400' :
79+
stream.status === 'Completed' ? 'bg-blue-500/20 text-blue-400' : 'bg-red-500/20 text-red-400'
80+
}`}>
81+
{stream.status}
82+
</span>
83+
</div>
84+
<div className="p-4 rounded-2xl bg-white/5 border border-glass-border">
85+
<label className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-1 block">Token</label>
86+
<span className="font-bold text-white">{stream.token}</span>
87+
</div>
88+
</div>
89+
90+
<div className="p-6 rounded-2xl border border-glass-border bg-gradient-to-br from-white/5 to-transparent">
91+
<label className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-4 block">Streaming Progress</label>
92+
93+
<div className="flex justify-between items-end mb-2">
94+
<span className="text-2xl font-black text-white">{stream.withdrawn}</span>
95+
<span className="text-slate-400 text-sm">of {stream.deposited} {stream.token}</span>
96+
</div>
97+
98+
<div className="h-3 w-full bg-slate-800 rounded-full overflow-hidden mb-3">
99+
<div
100+
className="h-full bg-accent shadow-[0_0_15px_rgba(16,185,129,0.5)] transition-all duration-1000 ease-out"
101+
style={{ width: `${progress}%` }}
102+
/>
103+
</div>
104+
105+
<p className="text-sm text-slate-400">
106+
{remaining} {stream.token} remaining to be streamed
107+
</p>
108+
</div>
109+
</div>
110+
111+
{/* Actions & Meta */}
112+
<div className="space-y-6">
113+
<div className="p-4 rounded-2xl bg-white/5 border border-glass-border">
114+
<label className="text-xs uppercase tracking-widest text-slate-500 font-bold mb-1 block">Created On</label>
115+
<p className="text-white font-medium">{stream.date}</p>
116+
</div>
117+
118+
<div className="space-y-3 pt-4">
119+
<p className="text-sm font-bold text-slate-400 px-1">Actions</p>
120+
<Button
121+
onClick={onTopUpClick}
122+
disabled={stream.status !== 'Active'}
123+
className="w-full justify-center h-12 text-lg"
124+
glow
125+
>
126+
Add Funds
127+
</Button>
128+
<button
129+
onClick={onCancelClick}
130+
disabled={stream.status !== 'Active'}
131+
className="w-full h-12 rounded-full border border-red-500/40 text-red-400 hover:bg-red-500/10 transition-all font-bold disabled:opacity-50 disabled:pointer-events-none active:scale-95"
132+
>
133+
Cancel Stream
134+
</button>
135+
</div>
136+
137+
<div className="p-4 rounded-2xl bg-red-500/5 border border-red-500/10 text-xs text-slate-400 italic">
138+
Note: Cancelling a stream will return any unspent funds ({remaining} {stream.token}) to your wallet. This action cannot be undone.
139+
</div>
140+
</div>
141+
</div>
142+
</div>
143+
</div>
144+
);
145+
};

frontend/components/dashboard/dashboard-view.tsx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "../stream-creation/StreamCreationWizard";
4141
import { TopUpModal } from "../stream-creation/TopUpModal";
4242
import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal";
43+
import { StreamDetailsModal } from "./StreamDetailsModal";
4344
import { Button } from "../ui/Button";
4445

4546
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -58,7 +59,8 @@ interface SidebarItem {
5859
type ModalState =
5960
| null
6061
| { type: "topup"; stream: Stream }
61-
| { type: "cancel"; stream: Stream };
62+
| { type: "cancel"; stream: Stream }
63+
| { type: "details"; stream: Stream };
6264

6365
interface StreamFormValues {
6466
recipient: string;
@@ -197,6 +199,7 @@ function renderStreams(
197199
snapshot: DashboardSnapshot | null,
198200
onTopUp: (stream: Stream) => void,
199201
onCancel: (stream: Stream) => void,
202+
onShowDetails: (stream: Stream) => void,
200203
) {
201204
if (!snapshot) return null;
202205
return (
@@ -220,7 +223,15 @@ function renderStreams(
220223
{snapshot.outgoingStreams
221224
.filter((s) => s.status === "Active")
222225
.map((stream) => (
223-
<tr key={stream.id}>
226+
<tr
227+
key={stream.id}
228+
className="cursor-pointer hover:bg-white/5"
229+
onClick={(e) => {
230+
// Prevent row click if clicking buttons
231+
if ((e.target as HTMLElement).closest('button')) return;
232+
onShowDetails(stream);
233+
}}
234+
>
224235
<td>{stream.date}</td>
225236
<td>
226237
<code className="text-xs">{stream.recipient}</code>
@@ -622,7 +633,12 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
622633
<div className="dashboard-content-stack mt-8">
623634
{renderStats(snapshot)}
624635
{renderAnalytics(snapshot)}
625-
{renderStreams(snapshot, (stream: Stream) => setModal({ type: "topup", stream }), (stream: Stream) => setModal({ type: "cancel", stream }))}
636+
{renderStreams(
637+
snapshot,
638+
(stream: Stream) => setModal({ type: "topup", stream }),
639+
(stream: Stream) => setModal({ type: "cancel", stream }),
640+
(stream: Stream) => setModal({ type: "details", stream })
641+
)}
626642
{renderRecentActivity(snapshot)}
627643
</div>
628644
);
@@ -911,6 +927,18 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
911927
/>
912928
)
913929
}
930+
931+
{/* Stream Details Modal */}
932+
{
933+
modal?.type === "details" && (
934+
<StreamDetailsModal
935+
stream={modal.stream}
936+
onClose={() => setModal(null)}
937+
onCancelClick={() => setModal({ type: "cancel", stream: modal.stream })}
938+
onTopUpClick={() => setModal({ type: "topup", stream: modal.stream })}
939+
/>
940+
)
941+
}
914942
</main>
915943
);
916944
}

0 commit comments

Comments
 (0)