Skip to content

Commit ea7f437

Browse files
authored
Merge pull request #377 from viccoder-oops/feat/portfolio-watchlist-delegation-topup-354-355-356-357
feat: portfolio summary card, stream watchlist, delegation UI, and balance top-up prompt
2 parents 0b64ddb + fd1acc6 commit ea7f437

6 files changed

Lines changed: 582 additions & 0 deletions

File tree

components/DelegatesSection.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
5+
const STORAGE_KEY = "sorostream-delegates";
6+
7+
interface Delegate {
8+
address: string;
9+
addedAt: string;
10+
/** Stream IDs this delegate can manage (empty = all streams) */
11+
streamIds: string[];
12+
}
13+
14+
function loadDelegates(): Delegate[] {
15+
if (typeof window === "undefined") return [];
16+
try {
17+
const raw = localStorage.getItem(STORAGE_KEY);
18+
return raw ? (JSON.parse(raw) as Delegate[]) : [];
19+
} catch {
20+
return [];
21+
}
22+
}
23+
24+
function saveDelegates(list: Delegate[]): void {
25+
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
26+
}
27+
28+
function truncateAddr(addr: string): string {
29+
if (addr.length <= 12) return addr;
30+
return `${addr.slice(0, 6)}${addr.slice(-4)}`;
31+
}
32+
33+
export default function DelegatesSection() {
34+
const [delegates, setDelegates] = useState<Delegate[]>([]);
35+
const [input, setInput] = useState("");
36+
const [inputError, setInputError] = useState("");
37+
const [adding, setAdding] = useState(false);
38+
39+
useEffect(() => {
40+
setDelegates(loadDelegates());
41+
}, []);
42+
43+
function validate(addr: string): string {
44+
if (!addr.trim()) return "Address is required.";
45+
if (!/^G[A-Z2-7]{55}$/.test(addr.trim()))
46+
return "Must be a valid Stellar public key (starts with G, 56 chars).";
47+
if (delegates.some((d) => d.address === addr.trim()))
48+
return "This address is already a delegate.";
49+
return "";
50+
}
51+
52+
async function handleAdd() {
53+
const err = validate(input);
54+
if (err) { setInputError(err); return; }
55+
setAdding(true);
56+
// Simulate SDK call
57+
await new Promise((r) => setTimeout(r, 500));
58+
const next: Delegate[] = [
59+
...delegates,
60+
{ address: input.trim(), addedAt: new Date().toISOString(), streamIds: [] },
61+
];
62+
saveDelegates(next);
63+
setDelegates(next);
64+
setInput("");
65+
setInputError("");
66+
setAdding(false);
67+
}
68+
69+
function handleRevoke(address: string) {
70+
const next = delegates.filter((d) => d.address !== address);
71+
saveDelegates(next);
72+
setDelegates(next);
73+
}
74+
75+
return (
76+
<div className="bg-gray-800 rounded-xl p-6 space-y-4 mb-8">
77+
<div>
78+
<h2 className="text-lg font-semibold">Delegation Management</h2>
79+
<p className="text-gray-400 text-sm mt-1">
80+
Grant other addresses the ability to manage your streams on your behalf.
81+
</p>
82+
</div>
83+
84+
{/* Add delegate */}
85+
<div className="space-y-2">
86+
<label htmlFor="delegate-address" className="text-gray-200 text-sm font-medium block">
87+
Delegate Address
88+
</label>
89+
<div className="flex gap-2">
90+
<input
91+
id="delegate-address"
92+
type="text"
93+
value={input}
94+
onChange={(e) => { setInput(e.target.value); setInputError(""); }}
95+
onKeyDown={(e) => { if (e.key === "Enter") void handleAdd(); }}
96+
placeholder="G… (Stellar public key)"
97+
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500"
98+
aria-invalid={!!inputError}
99+
aria-describedby={inputError ? "delegate-input-error" : undefined}
100+
/>
101+
<button
102+
onClick={() => void handleAdd()}
103+
disabled={adding}
104+
className="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500"
105+
>
106+
{adding ? "Adding…" : "Add Delegate"}
107+
</button>
108+
</div>
109+
{inputError && (
110+
<p id="delegate-input-error" className="text-red-400 text-xs">{inputError}</p>
111+
)}
112+
</div>
113+
114+
{/* Current delegates */}
115+
{delegates.length === 0 ? (
116+
<p className="text-gray-500 text-sm text-center py-4">No delegates added yet.</p>
117+
) : (
118+
<ul className="space-y-2">
119+
{delegates.map((d) => (
120+
<li
121+
key={d.address}
122+
className="flex items-center gap-3 bg-gray-700/50 rounded-lg px-4 py-3"
123+
>
124+
<div className="flex-1 min-w-0">
125+
<p className="text-white text-sm font-mono truncate" title={d.address}>
126+
{truncateAddr(d.address)}
127+
</p>
128+
<p className="text-gray-500 text-xs mt-0.5">
129+
{d.streamIds.length > 0
130+
? `Can manage streams: ${d.streamIds.join(", ")}`
131+
: "Can manage all streams"}
132+
</p>
133+
</div>
134+
<button
135+
onClick={() => handleRevoke(d.address)}
136+
className="text-red-400 hover:text-red-300 text-sm px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 rounded"
137+
aria-label={`Revoke delegate ${d.address}`}
138+
>
139+
Revoke
140+
</button>
141+
</li>
142+
))}
143+
</ul>
144+
)}
145+
</div>
146+
);
147+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"use client";
2+
3+
import { useMemo } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import type { StreamData } from "@/src/lib/sorostream";
6+
7+
interface PortfolioSummaryCardProps {
8+
streams: StreamData[];
9+
walletAddress: string | null;
10+
}
11+
12+
const SECONDS_PER_MONTH = 2592000;
13+
14+
function formatMonthly(stroopsPerSecond: number): string {
15+
const monthly = (stroopsPerSecond * SECONDS_PER_MONTH) / 10_000_000;
16+
return monthly.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
17+
}
18+
19+
export default function PortfolioSummaryCard({ streams, walletAddress }: PortfolioSummaryCardProps) {
20+
const router = useRouter();
21+
const searchParams = useSearchParams();
22+
23+
const { outflow, inflow } = useMemo(() => {
24+
const active = streams.filter((s) => s.status === "Active");
25+
let out = 0;
26+
let inn = 0;
27+
for (const s of active) {
28+
if (walletAddress && s.sender.includes(walletAddress.slice(0, 5))) {
29+
out += s.flowRate;
30+
} else if (walletAddress && s.recipient.includes(walletAddress.slice(0, 5))) {
31+
inn += s.flowRate;
32+
} else {
33+
// fallback when no wallet — don't count
34+
}
35+
}
36+
return { outflow: out, inflow: inn };
37+
}, [streams, walletAddress]);
38+
39+
const net = inflow - outflow;
40+
41+
function applyFilter(type: "outflow" | "inflow" | "net") {
42+
const params = new URLSearchParams(searchParams.toString());
43+
// Toggle: if already set to this filter clear it
44+
if (params.get("flowFilter") === type) {
45+
params.delete("flowFilter");
46+
} else {
47+
params.set("flowFilter", type);
48+
}
49+
router.replace(`/dashboard?${params.toString()}`);
50+
}
51+
52+
return (
53+
<div className="bg-gray-800 rounded-xl p-5 mb-6 border border-gray-700">
54+
<h2 className="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wide">
55+
Monthly Portfolio Summary
56+
</h2>
57+
<div className="grid grid-cols-3 gap-3">
58+
{/* Outflow */}
59+
<button
60+
onClick={() => applyFilter("outflow")}
61+
className="flex flex-col items-start p-3 rounded-lg bg-gray-700/50 hover:bg-red-900/20 border border-transparent hover:border-red-700/40 transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
62+
title="Click to filter outgoing streams"
63+
>
64+
<span className="text-xs text-gray-400 mb-1">Monthly Outflow</span>
65+
<span className="text-lg font-bold text-red-400 font-mono">
66+
{outflow > 0 ? `-${formatMonthly(outflow)}` : "0.00"}
67+
</span>
68+
</button>
69+
70+
{/* Inflow */}
71+
<button
72+
onClick={() => applyFilter("inflow")}
73+
className="flex flex-col items-start p-3 rounded-lg bg-gray-700/50 hover:bg-green-900/20 border border-transparent hover:border-green-700/40 transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500"
74+
title="Click to filter incoming streams"
75+
>
76+
<span className="text-xs text-gray-400 mb-1">Monthly Inflow</span>
77+
<span className="text-lg font-bold text-green-400 font-mono">
78+
{inflow > 0 ? `+${formatMonthly(inflow)}` : "0.00"}
79+
</span>
80+
</button>
81+
82+
{/* Net */}
83+
<button
84+
onClick={() => applyFilter("net")}
85+
className="flex flex-col items-start p-3 rounded-lg bg-gray-700/50 hover:bg-blue-900/20 border border-transparent hover:border-blue-700/40 transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
86+
title="Click to see net flow"
87+
>
88+
<span className="text-xs text-gray-400 mb-1">Net Monthly</span>
89+
<span
90+
className={`text-lg font-bold font-mono ${
91+
net > 0 ? "text-green-400" : net < 0 ? "text-red-400" : "text-gray-300"
92+
}`}
93+
>
94+
{net > 0
95+
? `+${formatMonthly(net)}`
96+
: net < 0
97+
? `-${formatMonthly(Math.abs(net))}`
98+
: "0.00"}
99+
</span>
100+
</button>
101+
</div>
102+
<p className="text-xs text-gray-500 mt-3">
103+
Based on active streams only. Values estimated over 30 days.
104+
</p>
105+
</div>
106+
);
107+
}

0 commit comments

Comments
 (0)