Skip to content

Commit 6edaef1

Browse files
committed
feat: implement stream details page with real-time SSE updates and contract interaction controls
1 parent 10216a4 commit 6edaef1

4 files changed

Lines changed: 215 additions & 5 deletions

File tree

frontend/src/app/streams/[id]/page.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useEffect, useState } from "react";
44
import { useParams, useRouter } from "next/navigation";
5-
import LiveCounter from "@/components/Livecounter";
5+
import ClaimableAmountDisplay from "@/components/streams/ClaimableAmountDisplay";
66
import ProgressBar from "@/components/Progressbar";
77
import { Button } from "@/components/ui/Button";
88
import toast from "react-hot-toast";
@@ -330,10 +330,12 @@ export default function StreamDetailsPage() {
330330
<div className="dashboard-panel__header">
331331
<h3>Claimable Balance</h3>
332332
</div>
333-
<LiveCounter
334-
initial={claimable}
335-
label="Available to withdraw"
336-
isPaused={stream.isPaused}
333+
<ClaimableAmountDisplay
334+
streamId={stream.id}
335+
initialAmount={claimable}
336+
ratePerSecond={parseFloat(stream.ratePerSecond) / 1e7}
337+
isActive={stream.isActive}
338+
isPaused={stream.isPaused}
337339
pausedAt={stream.pausedAt}
338340
/>
339341
</div>
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useClaimableAmount } from "@/hooks/useClaimableAmount";
5+
6+
interface ClaimableAmountDisplayProps {
7+
streamId: string;
8+
initialAmount: number; // The starting claimable amount
9+
ratePerSecond: number; // Rate in tokens/sec
10+
isPaused?: boolean;
11+
isActive: boolean;
12+
label?: string;
13+
pausedAt?: string;
14+
}
15+
16+
export default function ClaimableAmountDisplay({
17+
streamId,
18+
initialAmount,
19+
ratePerSecond,
20+
isPaused = false,
21+
isActive,
22+
label = "Available to withdraw",
23+
pausedAt,
24+
}: ClaimableAmountDisplayProps) {
25+
const { claimable, tick } = useClaimableAmount({
26+
streamId,
27+
initialClaimable: initialAmount,
28+
ratePerSecond,
29+
isActive,
30+
isPaused,
31+
});
32+
33+
const [highlight, setHighlight] = useState(false);
34+
35+
useEffect(() => {
36+
if (tick > 0 && isActive && !isPaused) {
37+
setHighlight(true);
38+
const timer = setTimeout(() => setHighlight(false), 200);
39+
return () => clearTimeout(timer);
40+
}
41+
}, [tick, isActive, isPaused]);
42+
43+
const formatPausedTime = (pausedAtStr: string | undefined): string => {
44+
if (!pausedAtStr) return "";
45+
try {
46+
// Stream pausedAt might be in seconds or a date string
47+
const parsed = parseInt(pausedAtStr);
48+
const isSeconds = parsed.toString() === pausedAtStr && parsed < 10000000000;
49+
const pausedDate = isSeconds ? new Date(parsed * 1000) : new Date(pausedAtStr);
50+
51+
const now = new Date();
52+
const diffMs = now.getTime() - pausedDate.getTime();
53+
const diffMins = Math.floor(diffMs / 60000);
54+
const diffHours = Math.floor(diffMs / 3600000);
55+
const diffDays = Math.floor(diffMs / 86400000);
56+
57+
if (diffDays > 0) return `Paused ${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
58+
if (diffHours > 0) return `Paused ${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
59+
if (diffMins > 0) return `Paused ${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
60+
return "Paused now";
61+
} catch {
62+
return "Paused";
63+
}
64+
};
65+
66+
return (
67+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
68+
<span
69+
style={{
70+
position: "relative",
71+
display: "inline-flex",
72+
width: "0.75rem",
73+
height: "0.75rem",
74+
}}
75+
>
76+
<span
77+
style={{
78+
position: "absolute",
79+
inset: 0,
80+
borderRadius: "999px",
81+
background: isPaused ? "#ef4444" : "#10b981",
82+
opacity: isPaused ? 0.5 : 0.75,
83+
animation: isPaused ? "none" : "pulse-slow 4s cubic-bezier(0.4, 0, 0.6, 1) infinite",
84+
}}
85+
/>
86+
<span
87+
style={{
88+
position: "relative",
89+
display: "inline-flex",
90+
width: "0.75rem",
91+
height: "0.75rem",
92+
borderRadius: "999px",
93+
background: isPaused ? "#ef4444" : "#10b981",
94+
}}
95+
/>
96+
</span>
97+
98+
<p style={{ margin: 0, fontSize: "0.92rem", color: "var(--text-muted)" }}>
99+
{isPaused ? (
100+
formatPausedTime(pausedAt)
101+
) : (
102+
<>
103+
{label}:{" "}
104+
<strong
105+
style={{
106+
fontSize: "1rem",
107+
color: highlight ? "#10b981" : "var(--text-main)",
108+
transition: "color 0.2s ease",
109+
fontVariantNumeric: "tabular-nums",
110+
}}
111+
>
112+
{claimable.toFixed(7)}
113+
</strong>
114+
</>
115+
)}
116+
</p>
117+
</div>
118+
);
119+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useState, useEffect, useRef } from "react";
2+
import { getClaimableAmount } from "@/lib/api/streams";
3+
4+
interface UseClaimableAmountProps {
5+
streamId: string;
6+
initialClaimable: number;
7+
ratePerSecond: number;
8+
isActive: boolean;
9+
isPaused?: boolean;
10+
}
11+
12+
export function useClaimableAmount({
13+
streamId,
14+
initialClaimable,
15+
ratePerSecond,
16+
isActive,
17+
isPaused
18+
}: UseClaimableAmountProps) {
19+
const [claimable, setClaimable] = useState(initialClaimable);
20+
const [tick, setTick] = useState(0);
21+
22+
// To keep track of the base amount and when we last synced
23+
const baseAmountRef = useRef(initialClaimable);
24+
const lastSyncTimeRef = useRef(Date.now());
25+
const rateRef = useRef(ratePerSecond);
26+
27+
useEffect(() => {
28+
baseAmountRef.current = initialClaimable;
29+
lastSyncTimeRef.current = Date.now();
30+
rateRef.current = ratePerSecond;
31+
setClaimable(initialClaimable);
32+
}, [initialClaimable, ratePerSecond]);
33+
34+
useEffect(() => {
35+
if (!isActive || isPaused) return;
36+
37+
// Local tick every second
38+
const intervalId = setInterval(() => {
39+
const now = Date.now();
40+
const elapsedSeconds = (now - lastSyncTimeRef.current) / 1000;
41+
42+
const currentClaimable = baseAmountRef.current + (rateRef.current * elapsedSeconds);
43+
setClaimable(currentClaimable);
44+
setTick((t) => t + 1);
45+
}, 1000);
46+
47+
return () => clearInterval(intervalId);
48+
}, [isActive, isPaused]);
49+
50+
// Periodic API resync every 30 seconds
51+
useEffect(() => {
52+
if (!isActive || isPaused) return;
53+
54+
const syncIntervalId = setInterval(async () => {
55+
try {
56+
const data = await getClaimableAmount(streamId);
57+
baseAmountRef.current = data.claimable;
58+
rateRef.current = data.ratePerSecond;
59+
lastSyncTimeRef.current = Date.now();
60+
setClaimable(data.claimable);
61+
} catch (err) {
62+
console.error("Failed to sync claimable amount", err);
63+
}
64+
}, 30000);
65+
66+
return () => clearInterval(syncIntervalId);
67+
}, [streamId, isActive, isPaused]);
68+
69+
return { claimable, tick };
70+
}

frontend/src/lib/api/streams.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
2+
3+
export async function getClaimableAmount(streamId: string) {
4+
const response = await fetch(`${baseUrl}/v1/streams/${streamId}`);
5+
if (!response.ok) {
6+
throw new Error("Failed to fetch stream details");
7+
}
8+
const data = await response.json();
9+
10+
const deposited = parseFloat(data.depositedAmount) / 1e7;
11+
const withdrawn = parseFloat(data.withdrawnAmount) / 1e7;
12+
const claimable = deposited - withdrawn;
13+
14+
return {
15+
claimable,
16+
ratePerSecond: parseFloat(data.ratePerSecond) / 1e7,
17+
lastUpdateTime: data.lastUpdateTime,
18+
};
19+
}

0 commit comments

Comments
 (0)