diff --git a/frontend/src/app/activity/activity-content.tsx b/frontend/src/app/activity/activity-content.tsx new file mode 100644 index 00000000..c65f0832 --- /dev/null +++ b/frontend/src/app/activity/activity-content.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useWallet } from "@/context/wallet-context"; +import { ActivityHistory } from "@/components/dashboard/ActivityHistory"; +import { BackendStreamEvent } from "@/lib/api-types"; +import { Button } from "@/components/ui/Button"; +import { Loader2, Download } from "lucide-react"; +import { formatAmount } from "@/lib/amount"; +import { downloadCSV } from "@/utils/csvExport"; + +const PAGE_SIZE = 10; +const API_BASE_URL = ( + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001" +).replace(/\/+$/, ""); + +const TABS = [ + { id: "ALL", label: "All" }, + { id: "CREATED", label: "Created" }, + { id: "WITHDRAWN", label: "Withdrawals" }, + { id: "TOPPED_UP", label: "Top-ups" }, + { id: "CANCELLED", label: "Cancellations" }, + { id: "PAUSED", label: "Paused/Resumed" }, +]; + +export default function ActivityContent() { + const { session, status } = useWallet(); + const [events, setEvents] = useState([]); + const [activeTab, setActiveTab] = useState("ALL"); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + + const fetchActivity = useCallback( + async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => { + if (!session?.publicKey) return; + setLoading(true); + + try { + const typeFilter = tab === "PAUSED" ? "PAUSED,RESUMED" : tab; + const typeQuery = tab === "ALL" ? "" : `&type=${encodeURIComponent(typeFilter)}`; + const url = + `${API_BASE_URL}/v1/events?address=${encodeURIComponent(session.publicKey)}` + + `&page=${pageNum}&limit=${PAGE_SIZE}${typeQuery}`; + + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Failed to fetch activity (${response.status})`); + } + const data = await response.json(); + + const next: BackendStreamEvent[] = Array.isArray(data?.events) + ? data.events + : []; + + setEvents((prev) => (append ? [...prev, ...next] : next)); + + if (typeof data?.hasMore === "boolean") { + setHasMore(data.hasMore); + } else { + setHasMore(next.length === PAGE_SIZE); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + console.error("Failed to fetch activity:", error); + if (!append) setEvents([]); + setHasMore(false); + } finally { + setLoading(false); + } + }, + [session], + ); + + useEffect(() => { + if (status !== "connected") return; + const controller = new AbortController(); + const initLoad = async () => { + await fetchActivity(1, activeTab, false, controller.signal); + }; + void initLoad(); + return () => controller.abort(); + }, [activeTab, status, fetchActivity]); + + const loadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchActivity(nextPage, activeTab, true); + }; + + const handleExportCSV = () => { + const csvData = events.map(event => ({ + 'Stream ID': event.streamId, + 'Event Type': event.eventType, + 'Amount': event.amount ? formatAmount(BigInt(event.amount), 7) : '0', + 'Timestamp': new Date(event.timestamp * 1000).toLocaleString(), + 'Transaction Hash': event.transactionHash, + 'Ledger': event.ledgerSequence, + })); + downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`); + }; + + if (status !== "connected") { + return ( +
+

Access Denied

+

+ Please connect your wallet to view your stream history. +

+
+ ); + } + + return ( +
+
+
+

Stream Activity

+

+ Track all your incoming and outgoing payment stream events. +

+
+ +
+ + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + + + {hasMore && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 79b6d7e1..f220cb16 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -1,173 +1,11 @@ -"use client"; +import type { Metadata } from "next"; +import ActivityContent from "./activity-content"; -import { useState, useEffect, useCallback } from "react"; -import { useWallet } from "@/context/wallet-context"; -import { ActivityHistory } from "@/components/dashboard/ActivityHistory"; -import { BackendStreamEvent } from "@/lib/api-types"; -import { Button } from "@/components/ui/Button"; -import { Loader2, Download } from "lucide-react"; -import { formatAmount } from "@/lib/amount"; -import { downloadCSV } from "@/utils/csvExport"; - -const PAGE_SIZE = 10; -const API_BASE_URL = ( - process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001" -).replace(/\/+$/, ""); - -const TABS = [ - { id: "ALL", label: "All" }, - { id: "CREATED", label: "Created" }, - { id: "WITHDRAWN", label: "Withdrawals" }, - { id: "TOPPED_UP", label: "Top-ups" }, - { id: "CANCELLED", label: "Cancellations" }, - { id: "PAUSED", label: "Paused/Resumed" }, -]; +export const metadata: Metadata = { + title: "Activity | FlowFi", + description: "View your stream activity and event history.", +}; export default function ActivityPage() { - const { session, status } = useWallet(); - const [events, setEvents] = useState([]); - const [activeTab, setActiveTab] = useState("ALL"); - const [loading, setLoading] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - - const fetchActivity = useCallback( - async (pageNum: number, tab: string, append: boolean = false, signal?: AbortSignal) => { - if (!session?.publicKey) return; - setLoading(true); - - try { - const typeFilter = tab === "PAUSED" ? "PAUSED,RESUMED" : tab; - const typeQuery = tab === "ALL" ? "" : `&type=${encodeURIComponent(typeFilter)}`; - const url = - `${API_BASE_URL}/v1/events?address=${encodeURIComponent(session.publicKey)}` + - `&page=${pageNum}&limit=${PAGE_SIZE}${typeQuery}`; - - const response = await fetch(url, { signal }); - if (!response.ok) { - throw new Error(`Failed to fetch activity (${response.status})`); - } - const data = await response.json(); - - const next: BackendStreamEvent[] = Array.isArray(data?.events) - ? data.events - : []; - - setEvents((prev) => (append ? [...prev, ...next] : next)); - - // Prefer the server-provided hasMore; fall back to a length heuristic. - if (typeof data?.hasMore === "boolean") { - setHasMore(data.hasMore); - } else { - setHasMore(next.length === PAGE_SIZE); - } - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") return; - console.error("Failed to fetch activity:", error); - if (!append) setEvents([]); - setHasMore(false); - } finally { - setLoading(false); - } - }, - [session], - ); - - useEffect(() => { - if (status !== "connected") return; - const controller = new AbortController(); - const initLoad = async () => { - await fetchActivity(1, activeTab, false, controller.signal); - }; - void initLoad(); - return () => controller.abort(); - }, [activeTab, status, fetchActivity]); - - const loadMore = () => { - const nextPage = page + 1; - setPage(nextPage); - fetchActivity(nextPage, activeTab, true); - }; - - const handleExportCSV = () => { - const csvData = events.map(event => ({ - 'Stream ID': event.streamId, - 'Event Type': event.eventType, - 'Amount': event.amount ? formatAmount(BigInt(event.amount), 7) : '0', - 'Timestamp': new Date(event.timestamp * 1000).toLocaleString(), - 'Transaction Hash': event.transactionHash, - 'Ledger': event.ledgerSequence, - })); - downloadCSV(csvData, `flowfi-activity-${Date.now()}.csv`); - }; - - if (status !== "connected") { - return ( -
-

Access Denied

-

- Please connect your wallet to view your stream history. -

-
- ); - } - - return ( -
-
-
-

Stream Activity

-

- Track all your incoming and outgoing payment stream events. -

-
- -
- - {/* Tabs */} -
- {TABS.map((tab) => ( - - ))} -
- - - - {hasMore && ( -
- -
- )} -
- ); + return ; } diff --git a/frontend/src/app/incoming/incoming-content.tsx b/frontend/src/app/incoming/incoming-content.tsx new file mode 100644 index 00000000..a5af7ede --- /dev/null +++ b/frontend/src/app/incoming/incoming-content.tsx @@ -0,0 +1,202 @@ +"use client"; + +import React from "react"; +import toast from "react-hot-toast"; +import TransactionTracker, { + type TransactionStatus, +} from "@/components/TransactionTracker"; +import { IncomingStreamCard } from "@/components/streams/IncomingStreamCard"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { useWallet } from "@/context/wallet-context"; +import { + type IncomingStreamRecord, +} from "@/lib/api/streams"; +import { toSorobanErrorMessage } from "@/lib/soroban"; +import { + useIncomingStreams, + useWithdrawIncomingStream, +} from "@/hooks/useIncomingStreams"; + +interface TrackerState { + status: TransactionStatus; + txHash?: string; + error?: string; + streamId?: string; +} + +function LoadingCard() { + return ( +
+ + +
+ + + +
+ +
+ ); +} + +export default function IncomingContent() { + const { session, status, isHydrated } = useWallet(); + const [tracker, setTracker] = React.useState({ + status: "idle", + }); + + const incomingStreamsQuery = useIncomingStreams(session?.publicKey); + const withdrawMutation = useWithdrawIncomingStream( + session, + session?.publicKey, + { + onSuccess: async (result, stream) => { + setTracker({ + status: "submitted", + txHash: result.txHash, + streamId: String(stream.streamId), + }); + toast.success(`Withdrawal submitted for stream #${stream.streamId}`); + + window.setTimeout(() => { + setTracker((current) => + current.txHash === result.txHash + ? { ...current, status: "confirmed" } + : current, + ); + }, 1500); + }, + onError: (error, stream) => { + const message = toSorobanErrorMessage(error); + setTracker({ + status: "failed", + error: message, + streamId: String(stream.streamId), + }); + toast.error(message); + }, + }, + ); + + const handleWithdraw = async (stream: IncomingStreamRecord) => { + setTracker({ + status: "signing", + streamId: String(stream.streamId), + }); + + try { + await withdrawMutation.mutateAsync(stream); + } catch { + // Errors are handled by the mutation callback so the UI stays consistent. + } + }; + + const isLoading = + !isHydrated || + (status === "connected" && incomingStreamsQuery.isLoading); + const streams = incomingStreamsQuery.data ?? []; + + return ( +
+
+
+

+ Incoming funds +

+
+
+

+ Streams paying into your wallet +

+

+ Review every active payment stream you receive, keep an eye on live accrual, + and withdraw funds the moment they become claimable. +

+
+ {status === "connected" && session?.publicKey && ( +
+ Recipient wallet +
+ {session.publicKey} +
+
+ )} +
+
+ + {!isHydrated ? ( +
+ + + +
+ ) : status !== "connected" ? ( +
+

+ Connect a wallet to view incoming streams +

+

+ Once your wallet is connected, this page will automatically load every stream + where you are the recipient and keep the claimable balance fresh. +

+
+ ) : incomingStreamsQuery.isError ? ( +
+

+ We couldn't load your incoming streams +

+

+ {incomingStreamsQuery.error instanceof Error + ? incomingStreamsQuery.error.message + : "Please try again in a moment."} +

+
+ ) : isLoading ? ( +
+ + + +
+ ) : streams.length === 0 ? ( +
+

+ No incoming streams yet +

+

+ When someone starts streaming funds to this wallet, the stream will show up here + with its current claimable balance and a withdraw action. +

+
+ ) : ( +
+ {streams.map((stream) => ( + { + void handleWithdraw(selectedStream); + }} + /> + ))} +
+ )} + + {tracker.status !== "idle" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/incoming/page.tsx b/frontend/src/app/incoming/page.tsx index c1adf1d1..3c833ed5 100644 --- a/frontend/src/app/incoming/page.tsx +++ b/frontend/src/app/incoming/page.tsx @@ -1,202 +1,11 @@ -"use client"; +import type { Metadata } from "next"; +import IncomingContent from "./incoming-content"; -import React from "react"; -import toast from "react-hot-toast"; -import TransactionTracker, { - type TransactionStatus, -} from "@/components/TransactionTracker"; -import { IncomingStreamCard } from "@/components/streams/IncomingStreamCard"; -import { Skeleton } from "@/components/ui/Skeleton"; -import { useWallet } from "@/context/wallet-context"; -import { - type IncomingStreamRecord, -} from "@/lib/api/streams"; -import { toSorobanErrorMessage } from "@/lib/soroban"; -import { - useIncomingStreams, - useWithdrawIncomingStream, -} from "@/hooks/useIncomingStreams"; - -interface TrackerState { - status: TransactionStatus; - txHash?: string; - error?: string; - streamId?: string; -} - -function LoadingCard() { - return ( -
- - -
- - - -
- -
- ); -} +export const metadata: Metadata = { + title: "Incoming Streams | FlowFi", + description: "Review and manage your incoming payment streams.", +}; export default function IncomingPage() { - const { session, status, isHydrated } = useWallet(); - const [tracker, setTracker] = React.useState({ - status: "idle", - }); - - const incomingStreamsQuery = useIncomingStreams(session?.publicKey); - const withdrawMutation = useWithdrawIncomingStream( - session, - session?.publicKey, - { - onSuccess: async (result, stream) => { - setTracker({ - status: "submitted", - txHash: result.txHash, - streamId: String(stream.streamId), - }); - toast.success(`Withdrawal submitted for stream #${stream.streamId}`); - - window.setTimeout(() => { - setTracker((current) => - current.txHash === result.txHash - ? { ...current, status: "confirmed" } - : current, - ); - }, 1500); - }, - onError: (error, stream) => { - const message = toSorobanErrorMessage(error); - setTracker({ - status: "failed", - error: message, - streamId: String(stream.streamId), - }); - toast.error(message); - }, - }, - ); - - const handleWithdraw = async (stream: IncomingStreamRecord) => { - setTracker({ - status: "signing", - streamId: String(stream.streamId), - }); - - try { - await withdrawMutation.mutateAsync(stream); - } catch { - // Errors are handled by the mutation callback so the UI stays consistent. - } - }; - - const isLoading = - !isHydrated || - (status === "connected" && incomingStreamsQuery.isLoading); - const streams = incomingStreamsQuery.data ?? []; - - return ( -
-
-
-

- Incoming funds -

-
-
-

- Streams paying into your wallet -

-

- Review every active payment stream you receive, keep an eye on live accrual, - and withdraw funds the moment they become claimable. -

-
- {status === "connected" && session?.publicKey && ( -
- Recipient wallet -
- {session.publicKey} -
-
- )} -
-
- - {!isHydrated ? ( -
- - - -
- ) : status !== "connected" ? ( -
-

- Connect a wallet to view incoming streams -

-

- Once your wallet is connected, this page will automatically load every stream - where you are the recipient and keep the claimable balance fresh. -

-
- ) : incomingStreamsQuery.isError ? ( -
-

- We couldn't load your incoming streams -

-

- {incomingStreamsQuery.error instanceof Error - ? incomingStreamsQuery.error.message - : "Please try again in a moment."} -

-
- ) : isLoading ? ( -
- - - -
- ) : streams.length === 0 ? ( -
-

- No incoming streams yet -

-

- When someone starts streaming funds to this wallet, the stream will show up here - with its current claimable balance and a withdraw action. -

-
- ) : ( -
- {streams.map((stream) => ( - { - void handleWithdraw(selectedStream); - }} - /> - ))} -
- )} - - {tracker.status !== "idle" && ( -
- -
- )} -
-
- ); + return ; } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 29c78809..a018bd46 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,429 +1,11 @@ -"use client"; +import type { Metadata } from "next"; +import SettingsContent from "./settings-content"; -import { useState, useEffect } from "react"; -import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react"; -import { STELLAR_NETWORK, shortenPublicKey } from "@/lib/wallet"; -import { useWallet } from "@/context/wallet-context"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { formatNetwork } from "@/lib/wallet"; -import toast from "react-hot-toast"; - -type DisplayCurrency = "USD" | "XLM" | "USDC"; -type AmountFormat = "full" | "compact"; -type DecimalPlaces = 2 | 4 | 7; - -// App version from package.json or env -const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; -const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; -const INDEXER_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +export const metadata: Metadata = { + title: "Settings | FlowFi", + description: "Manage your FlowFi preferences.", +}; export default function SettingsPage() { - const router = useRouter(); - const { session, disconnect, isHydrated } = useWallet(); - - const [browserPush, setBrowserPush] = useState(false); - const [theme, setTheme] = useState<"light" | "dark" | "system">(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("flowfi-theme") as - | "light" - | "dark" - | "system" - | null; - if (saved) { - document.documentElement.classList.toggle("dark", saved === "dark"); - return saved; - } - } - return "dark"; - }); - - const [displayCurrency, setDisplayCurrency] = useState(() => { - if (typeof window !== "undefined") { - return (localStorage.getItem("flowfi-currency") as DisplayCurrency) || "USD"; - } - return "USD"; - }); - - const [amountFormat, setAmountFormat] = useState(() => { - if (typeof window !== "undefined") { - return (localStorage.getItem("flowfi-amount-format") as AmountFormat) || "full"; - } - return "full"; - }); - - const [decimalPlaces, setDecimalPlaces] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("flowfi-decimal-places"); - return (saved ? parseInt(saved, 10) : 7) as DecimalPlaces; - } - return 7; - }); - - const [lastLedger, setLastLedger] = useState("Loading..."); - - const [copied, setCopied] = useState(false); - - const toggleTheme = (newTheme: "light" | "dark" | "system") => { - setTheme(newTheme); - localStorage.setItem("flowfi-theme", newTheme); - if (newTheme === "system") { - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - document.documentElement.classList.toggle("dark", prefersDark); - } else { - document.documentElement.classList.toggle("dark", newTheme === "dark"); - } - }; - - const copyAddress = async () => { - if (session?.publicKey) { - await navigator.clipboard.writeText(session.publicKey); - setCopied(true); - toast.success("Address copied to clipboard"); - setTimeout(() => setCopied(false), 1500); - } - }; - - const handleDisconnect = () => { - disconnect(); - toast.success("Wallet disconnected"); - router.push("/"); - }; - - const handleBrowserPushToggle = async () => { - if (!browserPush) { - try { - await Notification.requestPermission(); - setBrowserPush(Notification.permission === "granted"); - if (Notification.permission === "granted") { - toast.success("Browser notifications enabled"); - } - } catch { - toast.error("Failed to enable notifications"); - } - } else { - setBrowserPush(false); - toast("Browser notifications disabled"); - } - }; - - // Fetch last ledger from indexer - useEffect(() => { - const fetchLastLedger = async () => { - try { - const response = await fetch(`${INDEXER_URL}/health`); - if (response.ok) { - const data = await response.json(); - if (data.ledger) { - setLastLedger(data.ledger.toString()); - } else { - setLastLedger("Unknown"); - } - } else { - setLastLedger("Unavailable"); - } - } catch { - setLastLedger("Error"); - } - }; - fetchLastLedger(); - }, []); - - if (!isHydrated) { - return ( -
-
Loading...
-
- ); - } - - return ( -
- - {/* Background Glow */} -
-
- -
-
- -
-

- Settings -

-

- Manage your FlowFi preferences -

-
- - {/* Browser Push Notifications */} -
-
-
- -
-
-

- Browser Notifications -

-

- Get notified about stream activity -

-
-
- - -
- - {/* Theme Toggle */} -
-
-
- {theme === "dark" ? : theme === "light" ? : } -
-
-

- Appearance -

-

- Choose your theme preference -

-
-
- -
- {(["light", "dark", "system"] as const).map((t) => ( - - ))} -
-
- - {/* Display Preferences */} -
-
-
- -
-
-

- Display Preferences -

-

- Customize how amounts are displayed -

-
-
- -
-
- - -
- -
- -
- {(["full", "compact"] as const).map((fmt) => ( - - ))} -
-
- -
- -
- {([2, 4, 7] as DecimalPlaces[]).map((places) => ( - - ))} -
-
-
-
- - {/* Wallet Section */} - {session ? ( -
-
-

- Connected Wallet -

-
- - {formatNetwork(session.network)} - - - {session.walletName} - -
-
- -
- {session.publicKey} - - - - {copied && ( - - Copied - - )} -
-
- ) : ( -
-

- Wallet Status -

-
- Not connected - - Connect Wallet - -
-
- )} - - {/* About Section */} -
-
-
- - - - -
-
-

About

-

App and contract information

-
-
- -
-
- App Version - {APP_VERSION} -
- -
- Contract Address -
- {shortenPublicKey(CONTRACT_ADDRESS)} - -
-
- -
- Network - - {STELLAR_NETWORK === "MAINNET" ? "Mainnet" : "Testnet"} - -
- -
- Indexer Last Ledger - {lastLedger} -
-
-
- - {/* Disconnect */} - {session && ( - - )} - -
-
-
- ); + return ; } diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx new file mode 100644 index 00000000..4648d14b --- /dev/null +++ b/frontend/src/app/settings/settings-content.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react"; +import { STELLAR_NETWORK, shortenPublicKey } from "@/lib/wallet"; +import { useWallet } from "@/context/wallet-context"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { formatNetwork } from "@/lib/wallet"; +import toast from "react-hot-toast"; + +type DisplayCurrency = "USD" | "XLM" | "USDC"; +type AmountFormat = "full" | "compact"; +type DecimalPlaces = 2 | 4 | 7; + +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0"; +const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY"; +const INDEXER_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; + +export default function SettingsContent() { + const router = useRouter(); + const { session, disconnect, isHydrated } = useWallet(); + + const [browserPush, setBrowserPush] = useState(false); + const [theme, setTheme] = useState<"light" | "dark" | "system">(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("flowfi-theme") as + | "light" + | "dark" + | "system" + | null; + if (saved) { + document.documentElement.classList.toggle("dark", saved === "dark"); + return saved; + } + } + return "dark"; + }); + + const [displayCurrency, setDisplayCurrency] = useState(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("flowfi-currency") as DisplayCurrency) || "USD"; + } + return "USD"; + }); + + const [amountFormat, setAmountFormat] = useState(() => { + if (typeof window !== "undefined") { + return (localStorage.getItem("flowfi-amount-format") as AmountFormat) || "full"; + } + return "full"; + }); + + const [decimalPlaces, setDecimalPlaces] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("flowfi-decimal-places"); + return (saved ? parseInt(saved, 10) : 7) as DecimalPlaces; + } + return 7; + }); + + const [lastLedger, setLastLedger] = useState("Loading..."); + + const [copied, setCopied] = useState(false); + + const toggleTheme = (newTheme: "light" | "dark" | "system") => { + setTheme(newTheme); + localStorage.setItem("flowfi-theme", newTheme); + if (newTheme === "system") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.classList.toggle("dark", prefersDark); + } else { + document.documentElement.classList.toggle("dark", newTheme === "dark"); + } + }; + + const copyAddress = async () => { + if (session?.publicKey) { + await navigator.clipboard.writeText(session.publicKey); + setCopied(true); + toast.success("Address copied to clipboard"); + setTimeout(() => setCopied(false), 1500); + } + }; + + const handleDisconnect = () => { + disconnect(); + toast.success("Wallet disconnected"); + router.push("/"); + }; + + const handleBrowserPushToggle = async () => { + if (!browserPush) { + try { + await Notification.requestPermission(); + setBrowserPush(Notification.permission === "granted"); + if (Notification.permission === "granted") { + toast.success("Browser notifications enabled"); + } + } catch { + toast.error("Failed to enable notifications"); + } + } else { + setBrowserPush(false); + toast("Browser notifications disabled"); + } + }; + + useEffect(() => { + const fetchLastLedger = async () => { + try { + const response = await fetch(`${INDEXER_URL}/health`); + if (response.ok) { + const data = await response.json(); + if (data.ledger) { + setLastLedger(data.ledger.toString()); + } else { + setLastLedger("Unknown"); + } + } else { + setLastLedger("Unavailable"); + } + } catch { + setLastLedger("Error"); + } + }; + fetchLastLedger(); + }, []); + + if (!isHydrated) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ + {/* Background Glow */} +
+
+ +
+
+ +
+

+ Settings +

+

+ Manage your FlowFi preferences +

+
+ + {/* Browser Push Notifications */} +
+
+
+ +
+
+

+ Browser Notifications +

+

+ Get notified about stream activity +

+
+
+ + +
+ + {/* Theme Toggle */} +
+
+
+ {theme === "dark" ? : theme === "light" ? : } +
+
+

+ Appearance +

+

+ Choose your theme preference +

+
+
+ +
+ {(["light", "dark", "system"] as const).map((t) => ( + + ))} +
+
+ + {/* Display Preferences */} +
+
+
+ +
+
+

+ Display Preferences +

+

+ Customize how amounts are displayed +

+
+
+ +
+
+ + +
+ +
+ +
+ {(["full", "compact"] as const).map((fmt) => ( + + ))} +
+
+ +
+ +
+ {([2, 4, 7] as DecimalPlaces[]).map((places) => ( + + ))} +
+
+
+
+ + {/* Wallet Section */} + {session ? ( +
+
+

+ Connected Wallet +

+
+ + {formatNetwork(session.network)} + + + {session.walletName} + +
+
+ +
+ {session.publicKey} + + + + {copied && ( + + Copied + + )} +
+
+ ) : ( +
+

+ Wallet Status +

+
+ Not connected + + Connect Wallet + +
+
+ )} + + {/* About Section */} +
+
+
+ + + + +
+
+

About

+

App and contract information

+
+
+ +
+
+ App Version + {APP_VERSION} +
+ +
+ Contract Address +
+ {shortenPublicKey(CONTRACT_ADDRESS)} + +
+
+ +
+ Network + + {STELLAR_NETWORK === "MAINNET" ? "Mainnet" : "Testnet"} + +
+ +
+ Indexer Last Ledger + {lastLedger} +
+
+
+ + {/* Disconnect */} + {session && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/app/streams/[id]/page.tsx b/frontend/src/app/streams/[id]/page.tsx index d0221026..d7a4f34b 100644 --- a/frontend/src/app/streams/[id]/page.tsx +++ b/frontend/src/app/streams/[id]/page.tsx @@ -1,665 +1,19 @@ -"use client"; - -import { useEffect, useState, useCallback, useMemo } from "react"; -import { useParams } from "next/navigation"; -import Link from "next/link"; -import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react"; -import { Button } from "@/components/ui/Button"; -import toast from "react-hot-toast"; -import { useWallet } from "@/context/wallet-context"; -import { useStreamEvents } from "@/hooks/useStreamEvents"; -import { - withdrawFromStream, - cancelStream, - topUpStream, - pauseStream, - resumeStream, - toBaseUnits, - toSorobanErrorMessage, -} from "@/lib/soroban"; -import { CancelConfirmModal } from "@/components/stream-creation/CancelConfirmModal"; -import type { BackendStreamEvent } from "@/lib/api-types"; -import { formatAmount, streamProgressPercent } from "@/utils/amount"; -import { shortenPublicKey } from "@/lib/wallet"; - -interface StreamDetail { - id: string; - streamId: number; - sender: string; - recipient: string; - tokenAddress: string; - tokenSymbol?: string; - depositedAmount: string; - withdrawnAmount: string; - ratePerSecond: string; - startTime: number; - endTime?: number; - lastUpdateTime: number; - isActive: boolean; - status: string; - isPaused?: boolean; - pausedAt?: string; - createdAt: string; - updatedAt: string; -} - -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; -const EVENTS_PER_PAGE = 10; - -// Token symbol mapping -const TOKEN_SYMBOLS: Record = { - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN": "XLM", - "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA": "USDC", - "CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY": "EURC", -}; - -// Event type styling -const EVENT_STYLES: Record = { - CREATED: { color: "#22c55e", icon: "✓", label: "Created" }, - TOPPED_UP: { color: "#3b82f6", icon: "+", label: "Topped Up" }, - WITHDRAWN: { color: "#8b5cf6", icon: "↓", label: "Withdrawn" }, - CANCELLED: { color: "#ef4444", icon: "×", label: "Cancelled" }, - COMPLETED: { color: "#10b981", icon: "✓", label: "Completed" }, - PAUSED: { color: "#f59e0b", icon: "⏸", label: "Paused" }, - RESUMED: { color: "#06b6d4", icon: "▶", label: "Resumed" }, - FEE_COLLECTED: { color: "#6b7280", icon: "$", label: "Fee" }, - FEE_CONFIG_UPDATED: { color: "#475569", icon: "⚙", label: "Fee Config" }, - ADMIN_TRANSFERRED: { color: "#475569", icon: "👤", label: "Admin Transfer" }, -}; - -export default function StreamDetailsPage() { - const params = useParams(); - const streamId = params.id as string; - const { session, isHydrated } = useWallet(); - - const [stream, setStream] = useState(null); - const [events, setEvents] = useState([]); - const [eventsPage, setEventsPage] = useState(1); - const [eventsTotal, setEventsTotal] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Action states - const [withdrawing, setWithdrawing] = useState(false); - const [cancelling, setCancelling] = useState(false); - const [pausing, setPausing] = useState(false); - const [resuming, setResuming] = useState(false); - const [topUpAmount, setTopUpAmount] = useState(""); - const [showTopUp, setShowTopUp] = useState(false); - const [showCancelModal, setShowCancelModal] = useState(false); - - // Live claimable counter - const [liveClaimable, setLiveClaimable] = useState(0n); - - // SSE integration - const { events: streamEvents } = useStreamEvents({ - streamIds: [streamId], - autoReconnect: true, - }); - - // Fetch stream data - const fetchStream = useCallback(async (signal?: AbortSignal) => { - if (!streamId) return; - try { - const response = await fetch(`${API_BASE_URL}/streams/${streamId}`, { signal }); - if (!response.ok) throw new Error("Stream not found"); - const data = await response.json(); - setStream(data); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") return; - setError(err instanceof Error ? err.message : "Failed to fetch stream"); - } - }, [streamId]); - - // Fetch events - const fetchEvents = useCallback(async (page: number, signal?: AbortSignal) => { - if (!streamId) return; - try { - const response = await fetch( - `${API_BASE_URL}/streams/${streamId}/events?page=${page}&limit=${EVENTS_PER_PAGE}`, - { signal } - ); - if (response.ok) { - const data = await response.json(); - setEvents(data.events || []); - setEventsTotal(data.total || 0); - } - } catch (err) { - if (err instanceof Error && err.name === "AbortError") return; - console.error("Failed to fetch events:", err); - } - }, [streamId]); - - // Initial load - useEffect(() => { - if (!isHydrated) return; - - const controller = new AbortController(); - - const loadData = async () => { - setLoading(true); - await Promise.all([fetchStream(controller.signal), fetchEvents(1, controller.signal)]); - setLoading(false); - }; - - loadData(); - - return () => controller.abort(); - }, [isHydrated, fetchStream, fetchEvents]); - - // Handle SSE events - useEffect(() => { - const controller = new AbortController(); - - const refreshStreamData = async () => { - if (streamEvents.length > 0) { - await Promise.all([fetchStream(controller.signal), fetchEvents(eventsPage, controller.signal)]); - } - }; - - refreshStreamData(); - - return () => controller.abort(); - }, [streamEvents, fetchStream, fetchEvents, eventsPage]); - - // Live claimable counter - useEffect(() => { - if (!stream) return; - - const ratePerSecond = BigInt(stream.ratePerSecond); - const withdrawn = BigInt(stream.withdrawnAmount); - const deposited = BigInt(stream.depositedAmount); - const lastUpdate = stream.lastUpdateTime; - - const updateClaimable = () => { - if (!stream.isActive || stream.isPaused) { - // Use the server's calculated value when stream is not active - setLiveClaimable(deposited - withdrawn); - return; - } - - const now = Math.floor(Date.now() / 1000); - const elapsed = BigInt(now - lastUpdate); - const accrued = elapsed * ratePerSecond; - const totalClaimable = deposited - withdrawn + accrued; - - // Cap at deposited amount - setLiveClaimable(totalClaimable > deposited ? deposited : totalClaimable); - }; - - updateClaimable(); - const interval = setInterval(updateClaimable, 1000); - - return () => clearInterval(interval); - }, [stream]); - - // User roles - const isSender = useMemo(() => { - if (!session || !stream) return false; - return session.publicKey === stream.sender; - }, [session, stream]); - - const isRecipient = useMemo(() => { - if (!session || !stream) return false; - return session.publicKey === stream.recipient; - }, [session, stream]); - - // Token symbol - const tokenSymbol = useMemo(() => { - if (!stream) return "??"; - return TOKEN_SYMBOLS[stream.tokenAddress] || stream.tokenAddress.slice(0, 4); - }, [stream]); - - // Handlers - const handleWithdraw = async () => { - if (!session) { - toast.error("Please connect your wallet"); - return; - } - Token: - setWithdrawing(true); - try { - await withdrawFromStream(session, { streamId: BigInt(streamId) }); - toast.success("Withdrawal successful!"); - await fetchStream(); - } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setWithdrawing(false); - } - }; - - const handleTopUp = async () => { - if (!session) { - toast.error("Please connect your wallet"); - return; - } - if (!topUpAmount || parseFloat(topUpAmount) <= 0) { - toast.error("Please enter a valid amount"); - return; - } - try { - const amount = toBaseUnits(topUpAmount); - await topUpStream(session, { streamId: BigInt(streamId), amount }); - toast.success("Stream topped up successfully!"); - setShowTopUp(false); - setTopUpAmount(""); - await fetchStream(); - } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } - }; - - const handlePause = async () => { - if (!session) { - toast.error("Please connect your wallet"); - return; - } - setPausing(true); - try { - await pauseStream(session, { streamId: BigInt(streamId) }); - toast.success("Stream paused"); - await fetchStream(); - } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setPausing(false); - } - }; - - const handleResume = async () => { - if (!session) { - toast.error("Please connect your wallet"); - return; - } - setResuming(true); - try { - await resumeStream(session, { streamId: BigInt(streamId) }); - toast.success("Stream resumed"); - await fetchStream(); - } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setResuming(false); - } - }; - - const handleCancel = async () => { - if (!session) { - toast.error("Please connect your wallet"); - return; - } - setCancelling(true); - try { - await cancelStream(session, { streamId: BigInt(streamId) }); - toast.success("Stream cancelled"); - setShowCancelModal(false); - await fetchStream(); - } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setCancelling(false); - } - }; - - const totalPages = Math.ceil(eventsTotal / EVENTS_PER_PAGE); - - if (loading) { - return ( -
-
-
-
-

Loading stream details...

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

{error || "Stream not found"}

- - ← Back to Dashboard - -
-
-
- ); - } - - const deposited = BigInt(stream.depositedAmount); - const withdrawn = BigInt(stream.withdrawnAmount); - const ratePerSecond = BigInt(stream.ratePerSecond); - const progressPercent = streamProgressPercent(withdrawn, deposited); - - return ( -
-
- {/* Header */} -
- - - -
-

Stream #{stream.streamId}

-

Stream Details

-
-
- -
-
- - {/* Stream Overview */} -
-
-
- - - -
-
- - - -
-
-
- - {/* Financial Overview */} -
- - - -
- - {/* Progress */} -
-

Stream Progress

-
-
-
-

- {formatAmount(withdrawn, 7)} / {formatAmount(deposited, 7)} {tokenSymbol} withdrawn -

-
- - {/* Actions */} - {(isSender || isRecipient) && stream.isActive && ( -
-

Actions

-
- {/* Recipient: Withdraw */} - {isRecipient && ( - - )} - - {/* Sender: Top Up */} - {isSender && ( - - )} - - {/* Sender: Pause/Resume */} - {isSender && ( - <> - {!stream.isPaused ? ( - - ) : ( - - )} - - )} - - {/* Sender: Cancel */} - {isSender && ( - - )} -
- - {/* Top Up Input */} - {showTopUp && ( -
- setTopUpAmount(e.target.value)} - className="flex-1 px-4 py-2 rounded-lg bg-black/40 border border-white/10 focus:border-accent outline-none" - /> - -
- )} -
- )} - - {/* Event History */} -
-

Event History

- - {events.length === 0 ? ( -

No events yet

- ) : ( - <> -
- {events.map((event) => ( - - ))} -
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {eventsPage} of {totalPages} - - -
- )} - - )} -
-
- - {/* Cancel Confirmation Modal */} - {showCancelModal && stream && ( - setShowCancelModal(false)} - /> - )} -
- ); -} - -// Helper Components -function StatusBadge({ status, isPaused }: { status: string; isPaused?: boolean }) { - const getStyles = () => { - if (isPaused) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"; - switch (status.toLowerCase()) { - case "active": - return "bg-green-500/20 text-green-400 border-green-500/30"; - case "completed": - return "bg-blue-500/20 text-blue-400 border-blue-500/30"; - case "cancelled": - return "bg-red-500/20 text-red-400 border-red-500/30"; - default: - return "bg-slate-500/20 text-slate-400 border-slate-500/30"; - } +import type { Metadata } from "next"; +import StreamDetailsContent from "./stream-details-content"; + +export async function generateMetadata( + { params }: { params: Promise<{ id: string }> } +): Promise { + const { id } = await params; + return { + title: `Stream #${id} | FlowFi`, + description: `View details and manage stream #${id}.`, }; - - return ( - - {isPaused ? "Paused" : status} - - ); -} - -function InfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); } -function StatCard({ - label, - value, - highlight, - live, -}: { - label: string; - value: string; - highlight?: boolean; - live?: boolean; -}) { - return ( -
-

{label}

-

- {value} - {live && } -

-
- ); -} - -function EventRow({ - event, - tokenSymbol, -}: { - event: BackendStreamEvent; - tokenSymbol: string; -}) { - const style = EVENT_STYLES[event.eventType] || { - color: "#6b7280", - icon: "•", - label: event.eventType, - }; - - return ( -
-
- {style.icon} -
-
-

{style.label}

-

- {new Date(event.timestamp * 1000).toLocaleString()} -

-
- {event.amount && ( -
-

- {formatAmount(BigInt(event.amount), 7)} {tokenSymbol} -

-
- )} -
- ); +export default async function StreamDetailsPage( + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + return ; } \ No newline at end of file diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx new file mode 100644 index 00000000..3a7565ad --- /dev/null +++ b/frontend/src/app/streams/[id]/stream-details-content.tsx @@ -0,0 +1,638 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import Link from "next/link"; +import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import toast from "react-hot-toast"; +import { useWallet } from "@/context/wallet-context"; +import { useStreamEvents } from "@/hooks/useStreamEvents"; +import { + withdrawFromStream, + cancelStream, + topUpStream, + pauseStream, + resumeStream, + toBaseUnits, + toSorobanErrorMessage, +} from "@/lib/soroban"; +import { CancelConfirmModal } from "@/components/stream-creation/CancelConfirmModal"; +import type { BackendStreamEvent } from "@/lib/api-types"; +import { formatAmount, streamProgressPercent } from "@/utils/amount"; +import { shortenPublicKey } from "@/lib/wallet"; + +interface StreamDetail { + id: string; + streamId: number; + sender: string; + recipient: string; + tokenAddress: string; + tokenSymbol?: string; + depositedAmount: string; + withdrawnAmount: string; + ratePerSecond: string; + startTime: number; + endTime?: number; + lastUpdateTime: number; + isActive: boolean; + status: string; + isPaused?: boolean; + pausedAt?: string; + createdAt: string; + updatedAt: string; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; +const EVENTS_PER_PAGE = 10; + +const TOKEN_SYMBOLS: Record = { + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCN": "XLM", + "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA": "USDC", + "CCWAMYJME4YOIUNAKVYEBYOG5I65QMKEX2NMN4OJAPXRPIF24ONPSHY": "EURC", +}; + +const EVENT_STYLES: Record = { + CREATED: { color: "#22c55e", icon: "✓", label: "Created" }, + TOPPED_UP: { color: "#3b82f6", icon: "+", label: "Topped Up" }, + WITHDRAWN: { color: "#8b5cf6", icon: "↓", label: "Withdrawn" }, + CANCELLED: { color: "#ef4444", icon: "×", label: "Cancelled" }, + COMPLETED: { color: "#10b981", icon: "✓", label: "Completed" }, + PAUSED: { color: "#f59e0b", icon: "⏸", label: "Paused" }, + RESUMED: { color: "#06b6d4", icon: "▶", label: "Resumed" }, + FEE_COLLECTED: { color: "#6b7280", icon: "$", label: "Fee" }, + FEE_CONFIG_UPDATED: { color: "#475569", icon: "⚙", label: "Fee Config" }, + ADMIN_TRANSFERRED: { color: "#475569", icon: "👤", label: "Admin Transfer" }, +}; + +export default function StreamDetailsContent({ streamId }: { streamId: string }) { + const { session, isHydrated } = useWallet(); + + const [stream, setStream] = useState(null); + const [events, setEvents] = useState([]); + const [eventsPage, setEventsPage] = useState(1); + const [eventsTotal, setEventsTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [withdrawing, setWithdrawing] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [pausing, setPausing] = useState(false); + const [resuming, setResuming] = useState(false); + const [topUpAmount, setTopUpAmount] = useState(""); + const [showTopUp, setShowTopUp] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + + const [liveClaimable, setLiveClaimable] = useState(0n); + + const { events: streamEvents } = useStreamEvents({ + streamIds: [streamId], + autoReconnect: true, + }); + + const fetchStream = useCallback(async (signal?: AbortSignal) => { + if (!streamId) return; + try { + const response = await fetch(`${API_BASE_URL}/streams/${streamId}`, { signal }); + if (!response.ok) throw new Error("Stream not found"); + const data = await response.json(); + setStream(data); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + setError(err instanceof Error ? err.message : "Failed to fetch stream"); + } + }, [streamId]); + + const fetchEvents = useCallback(async (page: number, signal?: AbortSignal) => { + if (!streamId) return; + try { + const response = await fetch( + `${API_BASE_URL}/streams/${streamId}/events?page=${page}&limit=${EVENTS_PER_PAGE}`, + { signal } + ); + if (response.ok) { + const data = await response.json(); + setEvents(data.events || []); + setEventsTotal(data.total || 0); + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return; + console.error("Failed to fetch events:", err); + } + }, [streamId]); + + useEffect(() => { + if (!isHydrated) return; + + const controller = new AbortController(); + + const loadData = async () => { + setLoading(true); + await Promise.all([fetchStream(controller.signal), fetchEvents(1, controller.signal)]); + setLoading(false); + }; + + loadData(); + + return () => controller.abort(); + }, [isHydrated, fetchStream, fetchEvents]); + + useEffect(() => { + const controller = new AbortController(); + + const refreshStreamData = async () => { + if (streamEvents.length > 0) { + await Promise.all([fetchStream(controller.signal), fetchEvents(eventsPage, controller.signal)]); + } + }; + + refreshStreamData(); + + return () => controller.abort(); + }, [streamEvents, fetchStream, fetchEvents, eventsPage]); + + useEffect(() => { + if (!stream) return; + + const ratePerSecond = BigInt(stream.ratePerSecond); + const withdrawn = BigInt(stream.withdrawnAmount); + const deposited = BigInt(stream.depositedAmount); + const lastUpdate = stream.lastUpdateTime; + + const updateClaimable = () => { + if (!stream.isActive || stream.isPaused) { + setLiveClaimable(deposited - withdrawn); + return; + } + + const now = Math.floor(Date.now() / 1000); + const elapsed = BigInt(now - lastUpdate); + const accrued = elapsed * ratePerSecond; + const totalClaimable = deposited - withdrawn + accrued; + + setLiveClaimable(totalClaimable > deposited ? deposited : totalClaimable); + }; + + updateClaimable(); + const interval = setInterval(updateClaimable, 1000); + + return () => clearInterval(interval); + }, [stream]); + + const isSender = useMemo(() => { + if (!session || !stream) return false; + return session.publicKey === stream.sender; + }, [session, stream]); + + const isRecipient = useMemo(() => { + if (!session || !stream) return false; + return session.publicKey === stream.recipient; + }, [session, stream]); + + const tokenSymbol = useMemo(() => { + if (!stream) return "??"; + return TOKEN_SYMBOLS[stream.tokenAddress] || stream.tokenAddress.slice(0, 4); + }, [stream]); + + const handleWithdraw = async () => { + if (!session) { + toast.error("Please connect your wallet"); + return; + } + setWithdrawing(true); + try { + await withdrawFromStream(session, { streamId: BigInt(streamId) }); + toast.success("Withdrawal successful!"); + await fetchStream(); + } catch (err) { + toast.error(toSorobanErrorMessage(err)); + } finally { + setWithdrawing(false); + } + }; + + const handleTopUp = async () => { + if (!session) { + toast.error("Please connect your wallet"); + return; + } + if (!topUpAmount || parseFloat(topUpAmount) <= 0) { + toast.error("Please enter a valid amount"); + return; + } + try { + const amount = toBaseUnits(topUpAmount); + await topUpStream(session, { streamId: BigInt(streamId), amount }); + toast.success("Stream topped up successfully!"); + setShowTopUp(false); + setTopUpAmount(""); + await fetchStream(); + } catch (err) { + toast.error(toSorobanErrorMessage(err)); + } + }; + + const handlePause = async () => { + if (!session) { + toast.error("Please connect your wallet"); + return; + } + setPausing(true); + try { + await pauseStream(session, { streamId: BigInt(streamId) }); + toast.success("Stream paused"); + await fetchStream(); + } catch (err) { + toast.error(toSorobanErrorMessage(err)); + } finally { + setPausing(false); + } + }; + + const handleResume = async () => { + if (!session) { + toast.error("Please connect your wallet"); + return; + } + setResuming(true); + try { + await resumeStream(session, { streamId: BigInt(streamId) }); + toast.success("Stream resumed"); + await fetchStream(); + } catch (err) { + toast.error(toSorobanErrorMessage(err)); + } finally { + setResuming(false); + } + }; + + const handleCancel = async () => { + if (!session) { + toast.error("Please connect your wallet"); + return; + } + setCancelling(true); + try { + await cancelStream(session, { streamId: BigInt(streamId) }); + toast.success("Stream cancelled"); + setShowCancelModal(false); + await fetchStream(); + } catch (err) { + toast.error(toSorobanErrorMessage(err)); + } finally { + setCancelling(false); + } + }; + + const totalPages = Math.ceil(eventsTotal / EVENTS_PER_PAGE); + + if (loading) { + return ( +
+
+
+
+

Loading stream details...

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

{error || "Stream not found"}

+ + ← Back to Dashboard + +
+
+
+ ); + } + + const deposited = BigInt(stream.depositedAmount); + const withdrawn = BigInt(stream.withdrawnAmount); + const ratePerSecond = BigInt(stream.ratePerSecond); + const progressPercent = streamProgressPercent(withdrawn, deposited); + + return ( +
+
+ {/* Header */} +
+ + + +
+

Stream #{stream.streamId}

+

Stream Details

+
+
+ +
+
+ + {/* Stream Overview */} +
+
+
+ + + +
+
+ + + +
+
+
+ + {/* Financial Overview */} +
+ + + +
+ + {/* Progress */} +
+

Stream Progress

+
+
+
+

+ {formatAmount(withdrawn, 7)} / {formatAmount(deposited, 7)} {tokenSymbol} withdrawn +

+
+ + {/* Actions */} + {(isSender || isRecipient) && stream.isActive && ( +
+

Actions

+
+ {isRecipient && ( + + )} + + {isSender && ( + + )} + + {isSender && ( + <> + {!stream.isPaused ? ( + + ) : ( + + )} + + )} + + {isSender && ( + + )} +
+ + {showTopUp && ( +
+ setTopUpAmount(e.target.value)} + className="flex-1 px-4 py-2 rounded-lg bg-black/40 border border-white/10 focus:border-accent outline-none" + /> + +
+ )} +
+ )} + + {/* Event History */} +
+

Event History

+ + {events.length === 0 ? ( +

No events yet

+ ) : ( + <> +
+ {events.map((event) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {eventsPage} of {totalPages} + + +
+ )} + + )} +
+
+ + {showCancelModal && stream && ( + setShowCancelModal(false)} + /> + )} +
+ ); +} + +function StatusBadge({ status, isPaused }: { status: string; isPaused?: boolean }) { + const getStyles = () => { + if (isPaused) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"; + switch (status.toLowerCase()) { + case "active": + return "bg-green-500/20 text-green-400 border-green-500/30"; + case "completed": + return "bg-blue-500/20 text-blue-400 border-blue-500/30"; + case "cancelled": + return "bg-red-500/20 text-red-400 border-red-500/30"; + default: + return "bg-slate-500/20 text-slate-400 border-slate-500/30"; + } + }; + + return ( + + {isPaused ? "Paused" : status} + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StatCard({ + label, + value, + highlight, + live, +}: { + label: string; + value: string; + highlight?: boolean; + live?: boolean; +}) { + return ( +
+

{label}

+

+ {value} + {live && } +

+
+ ); +} + +function EventRow({ + event, + tokenSymbol, +}: { + event: BackendStreamEvent; + tokenSymbol: string; +}) { + const style = EVENT_STYLES[event.eventType] || { + color: "#6b7280", + icon: "•", + label: event.eventType, + }; + + return ( +
+
+ {style.icon} +
+
+

{style.label}

+

+ {new Date(event.timestamp * 1000).toLocaleString()} +

+
+ {event.amount && ( +
+

+ {formatAmount(BigInt(event.amount), 7)} {tokenSymbol} +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/streams/create/create-stream-content.tsx b/frontend/src/app/streams/create/create-stream-content.tsx new file mode 100644 index 00000000..4a116380 --- /dev/null +++ b/frontend/src/app/streams/create/create-stream-content.tsx @@ -0,0 +1,214 @@ +"use client"; + +import React, { useState } from "react"; +import { + createStream, + toBaseUnits, + toDurationSeconds, + getTokenAddress, + toSorobanErrorMessage, + TOKEN_ADDRESSES +} from "@/lib/soroban"; +import { hasValidPrecision, validateAmountInput } from "@/utils/amount"; +import { toast } from "react-hot-toast"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { useWallet } from "@/context/wallet-context"; + +const TOKEN_DECIMALS = 7; + +export default function CreateStreamContent() { + const { status, session } = useWallet(); + const router = useRouter(); + const [nowTimestamp] = useState(() => Date.now()); + const [loading, setLoading] = useState(false); + const [txState, setTxState] = useState<"idle" | "signing" | "submitted" | "confirming">("idle"); + const [formData, setFormData] = useState({ + recipient: "", + token: "XLM", + amount: "", + duration: "30", + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (status !== "connected" || !session) { + toast.error("Please connect your wallet first."); + return; + } + + const validationError = validateAmountInput(formData.amount, TOKEN_DECIMALS); + if (validationError) { + toast.error(validationError); + return; + } + + setLoading(true); + setTxState("signing"); + + try { + const amountBigInt = toBaseUnits(formData.amount); + const durationBigInt = toDurationSeconds(formData.duration, "days"); + const tokenAddress = getTokenAddress(formData.token); + + const result = await createStream(session, { + recipient: formData.recipient, + tokenAddress, + amount: amountBigInt, + durationSeconds: durationBigInt, + }); + + if (result.success) { + setTxState("confirming"); + toast.success("Stream created successfully!"); + setTimeout(() => { + router.push("/dashboard"); + }, 2000); + } + } catch (error) { + console.error("Stream creation failed:", error); + toast.error(toSorobanErrorMessage(error)); + } finally { + setLoading(false); + setTxState("idle"); + } + }; + + const getButtonText = () => { + if (!loading) return "Start Streaming"; + switch (txState) { + case "signing": return "Confirm in Wallet..."; + case "submitted": return "Submitting to Network..."; + case "confirming": return "Finalizing Stream..."; + default: return "Processing..."; + } + }; + + const amountError = formData.amount + ? validateAmountInput(formData.amount, TOKEN_DECIMALS) + : null; + + return ( +
+ + + Back to Dashboard + + +
+

Create New Stream

+

+ Set up a real-time payment stream to any Stellar address. +

+ +
+
+ + setFormData({ ...formData, recipient: e.target.value })} + required + /> +
+ +
+
+ + +
+
+ + { + const newValue = e.target.value; + if (newValue === '' || /^\d*\.?\d*$/.test(newValue)) { + if (hasValidPrecision(newValue, TOKEN_DECIMALS)) { + setFormData({ ...formData, amount: newValue }); + } + } + }} + required + /> + {amountError && ( +

{amountError}

+ )} +
+
+ +
+ + setFormData({ ...formData, duration: e.target.value })} + required + /> +
+ +
+
+ Streaming Rate + + {formData.amount && formData.duration + ? (Number(formData.amount) / (Number(formData.duration) * 86400)).toFixed(8) + : "0.00000000"} {formData.token}/sec + +
+
+ Estimated End Date + + {new Date(nowTimestamp + Number(formData.duration || 0) * 86400000).toLocaleDateString()} + +
+
+ + + + {status !== "connected" && ( +

+ Please connect your wallet to create a stream. +

+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/streams/create/page.tsx b/frontend/src/app/streams/create/page.tsx index 339fcdfc..f1c7cfd9 100644 --- a/frontend/src/app/streams/create/page.tsx +++ b/frontend/src/app/streams/create/page.tsx @@ -1,5 +1,13 @@ -"use client"; +import type { Metadata } from "next"; +import CreateStreamContent from "./create-stream-content"; +export const metadata: Metadata = { + title: "Create Stream | FlowFi", + description: "Set up a new real-time payment stream.", +}; + +export default function CreateStreamPage() { + return ; import React, { useState } from "react"; import { createStream, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b42646a8..821f7a99 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -10,7 +10,12 @@ export default defineConfig({ include: ['src/__tests__/**/*.{test,spec}.{ts,tsx}', 'src/**/*.{test,spec}.{ts,tsx}'], coverage: { reporter: ['text', 'json', 'html'], - include: ['src/utils/**', 'src/hooks/**', 'src/components/**'], + include: ['src/**'], + exclude: [ + 'src/**/*.{test,spec}.{ts,tsx}', + 'src/**/__tests__/**', + ], + all: true, thresholds: { functions: 20, lines: 20,