diff --git a/.lintstagedrc b/.lintstagedrc index 9f270bb..3f4a7aa 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,5 +1,5 @@ { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{json,md,css}": ["prettier --write"], - "*.rs": ["cargo fmt --check"] + "*.rs": ["rustfmt --check"] } diff --git a/__tests__/components/connect-wallet-button.test.tsx b/__tests__/components/connect-wallet-button.test.tsx index f7de6f1..51ab99d 100644 --- a/__tests__/components/connect-wallet-button.test.tsx +++ b/__tests__/components/connect-wallet-button.test.tsx @@ -1,28 +1,32 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ConnectWalletButton } from '@/components/layout/connect-wallet-button' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ConnectWalletButton } from "@/components/layout/connect-wallet-button"; const mockUseWallet = vi.hoisted(() => vi.fn(() => ({ - address: null, + address: null as string | null, isConnected: false, connecting: false, connect: vi.fn(), disconnect: vi.fn(), - walletId: null, - })) -) + walletId: null as string | null, + })), +); -vi.mock('@/hooks/use-wallet', () => ({ useWallet: mockUseWallet })) +vi.mock("@/hooks/use-wallet", () => ({ useWallet: mockUseWallet })); -vi.mock('@/components/providers/wallet-provider', () => ({ +vi.mock("@/components/providers/wallet-provider", () => ({ WALLET_OPTIONS: [ - { id: 'freighter', name: 'Freighter', detail: 'Browser extension · stellar.org' }, - { id: 'xbull', name: 'xBull', detail: 'Extension & web' }, + { + id: "freighter", + name: "Freighter", + detail: "Browser extension · stellar.org", + }, + { id: "xbull", name: "xBull", detail: "Extension & web" }, ], -})) +})); -describe('ConnectWalletButton — disconnected', () => { +describe("ConnectWalletButton — disconnected", () => { beforeEach(() => { mockUseWallet.mockReturnValue({ address: null, @@ -31,56 +35,68 @@ describe('ConnectWalletButton — disconnected', () => { connect: vi.fn(), disconnect: vi.fn(), walletId: null, - }) - }) + }); + }); it('renders "Connect wallet" button', () => { - render() - expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument() - }) + render(); + expect( + screen.getByRole("button", { name: /connect wallet/i }), + ).toBeInTheDocument(); + }); - it('opens wallet selection dialog on click', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + it("opens wallet selection dialog on click", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); await waitFor(() => - expect(screen.getByText(/choose a stellar wallet/i)).toBeInTheDocument() - ) - }) + expect(screen.getByText(/choose a stellar wallet/i)).toBeInTheDocument(), + ); + }); - it('lists wallet options in dialog', async () => { - render() - fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })) + it("lists wallet options in dialog", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); await waitFor(() => { - expect(screen.getByText('Freighter')).toBeInTheDocument() - expect(screen.getByText('xBull')).toBeInTheDocument() - }) - }) + expect(screen.getByText("Freighter")).toBeInTheDocument(); + expect(screen.getByText("xBull")).toBeInTheDocument(); + }); + }); - it('calls connect with wallet id when option is clicked', async () => { - const connect = vi.fn() + it("calls connect with wallet id when option is clicked", async () => { + const connect = vi.fn(); mockUseWallet.mockReturnValue({ - address: null, isConnected: false, connecting: false, connect, disconnect: vi.fn(), walletId: null, - }) - render() - fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })) - await waitFor(() => screen.getByText('Freighter')) - fireEvent.click(screen.getByText('Freighter').closest('button')!) - expect(connect).toHaveBeenCalledWith('freighter') - }) + address: null, + isConnected: false, + connecting: false, + connect, + disconnect: vi.fn(), + walletId: null, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); + await waitFor(() => screen.getByText("Freighter")); + fireEvent.click(screen.getByText("Freighter").closest("button")!); + await waitFor(() => expect(connect).toHaveBeenCalledWith("freighter")); + }); - it('disables wallet buttons while connecting', async () => { + it("disables wallet buttons while connecting", async () => { mockUseWallet.mockReturnValue({ - address: null, isConnected: false, connecting: true, connect: vi.fn(), disconnect: vi.fn(), walletId: null, - }) - render() - fireEvent.click(screen.getByRole('button', { name: /connect wallet/i })) - await waitFor(() => screen.getByText('Freighter')) - expect(screen.getByText('Freighter').closest('button')).toBeDisabled() - }) -}) + address: null, + isConnected: false, + connecting: true, + connect: vi.fn(), + disconnect: vi.fn(), + walletId: null, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: /connect wallet/i })); + await waitFor(() => screen.getByText("Freighter")); + expect(screen.getByText("Freighter").closest("button")).toBeDisabled(); + }); +}); -describe('ConnectWalletButton — connected', () => { - const ADDR = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AB' +describe("ConnectWalletButton — connected", () => { + const ADDR = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890AB"; beforeEach(() => { mockUseWallet.mockReturnValue({ @@ -89,38 +105,49 @@ describe('ConnectWalletButton — connected', () => { connecting: false, connect: vi.fn(), disconnect: vi.fn(), - walletId: 'freighter', - }) - }) + walletId: "freighter", + }); + }); - it('renders shortened address instead of connect button', () => { - render() - expect(screen.queryByRole('button', { name: /connect wallet/i })).not.toBeInTheDocument() + it("renders shortened address instead of connect button", () => { + render(); + expect( + screen.queryByRole("button", { name: /connect wallet/i }), + ).not.toBeInTheDocument(); // Shortened address is shown inside the dropdown trigger button - expect(screen.getByRole('button')).toBeInTheDocument() - }) + expect(screen.getByRole("button")).toBeInTheDocument(); + }); - it('shows disconnect option in dropdown', async () => { - render() - fireEvent.click(screen.getByRole('button')) - await waitFor(() => expect(screen.getByText(/disconnect/i)).toBeInTheDocument()) - }) + it("shows disconnect option in dropdown", async () => { + render(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => + expect(screen.getByText(/disconnect/i)).toBeInTheDocument(), + ); + }); - it('calls disconnect when disconnect is clicked', async () => { - const disconnect = vi.fn() + it("calls disconnect when disconnect is clicked", async () => { + const disconnect = vi.fn(); mockUseWallet.mockReturnValue({ - address: ADDR, isConnected: true, connecting: false, connect: vi.fn(), disconnect, walletId: 'freighter', - }) - render() - fireEvent.click(screen.getByRole('button')) - await waitFor(() => screen.getByText(/disconnect/i)) - fireEvent.click(screen.getByText(/disconnect/i)) - expect(disconnect).toHaveBeenCalled() - }) + address: ADDR, + isConnected: true, + connecting: false, + connect: vi.fn(), + disconnect, + walletId: "freighter", + }); + render(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => screen.getByText(/disconnect/i)); + fireEvent.click(screen.getByText(/disconnect/i)); + expect(disconnect).toHaveBeenCalled(); + }); - it('shows copy address option', async () => { - render() - fireEvent.click(screen.getByRole('button')) - await waitFor(() => expect(screen.getByText(/copy address/i)).toBeInTheDocument()) - }) -}) + it("shows copy address option", async () => { + render(); + fireEvent.click(screen.getByRole("button")); + await waitFor(() => + expect(screen.getByText(/copy address/i)).toBeInTheDocument(), + ); + }); +}); diff --git a/app/app/analytics/page.tsx b/app/app/analytics/page.tsx index 096dd5e..a5d2142 100644 --- a/app/app/analytics/page.tsx +++ b/app/app/analytics/page.tsx @@ -1,25 +1,44 @@ -'use client' - -import { useEffect, useMemo, useState, Suspense } from 'react' -import dynamic from 'next/dynamic' -import Link from 'next/link' -import { ArrowLeft, BarChart3, Clock3, TrendingUp, Wallet2 } from 'lucide-react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Badge } from '@/components/ui/badge' -import { SectionErrorBoundary } from '@/components/error-boundary/section-error-boundary' -import { useStreams } from '@/hooks/use-streams' -import { useNetwork } from '@/components/providers/network-provider' -import { getAllTokens } from '@/lib/stellar' -import type { StreamData } from '@/types/stream' +"use client"; + +import { useEffect, useMemo, useState, Suspense } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { + ArrowLeft, + BarChart3, + Clock3, + TrendingUp, + Wallet2, +} from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { SectionErrorBoundary } from "@/components/error-boundary/section-error-boundary"; +import { useStreams } from "@/hooks/use-streams"; +import { useNetwork } from "@/components/providers/network-provider"; +import { getAllTokens } from "@/lib/stellar"; +import { formatCompactAmount } from "@/lib/stream-utils"; +import type { StreamData } from "@/types/stream"; const AnalyticsCharts = dynamic( - () => import('@/components/analytics/charts').then((m) => m.AnalyticsCharts), + () => import("@/components/analytics/charts").then((m) => m.AnalyticsCharts), { loading: () => , ssr: false, }, -) +); function ChartSkeleton() { return ( @@ -33,64 +52,96 @@ function ChartSkeleton() {
- ) + ); } interface AnalyticsSnapshot { - totalVolume: bigint - activeCount: number - totalStreams: number - averageDurationDays: number - tokenShares: Array<{ symbol: string; amount: bigint; count: number }> - series: Array<{ label: string; count: number }> - topTokens: Array<{ symbol: string; amount: bigint; count: number }> + totalVolume: bigint; + activeCount: number; + totalStreams: number; + averageDurationDays: number; + tokenShares: Array<{ symbol: string; amount: bigint; count: number }>; + series: Array<{ label: string; count: number }>; + topTokens: Array<{ symbol: string; amount: bigint; count: number }>; } const RANGE_OPTIONS = [ - { value: '7d', label: '7 days' }, - { value: '30d', label: '30 days' }, - { value: '90d', label: '90 days' }, - { value: 'all', label: 'All time' }, -] as const - -function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot { - const now = Math.floor(Date.now() / 1000) - const cutoff = range === 'all' ? 0 : Date.now() - Number.parseInt(range.replace('d', ''), 10) * 24 * 60 * 60 * 1000 - const filtered = streams.filter((stream) => Number(stream.startTime) * 1000 >= cutoff || range === 'all') - - const totalVolume = filtered.reduce((sum, stream) => sum + stream.depositedAmount, 0n) - const activeCount = filtered.filter((stream) => !stream.cancelled && Number(stream.endTime) > now).length - const totalStreams = filtered.length - const averageDurationDays = filtered.length > 0 - ? filtered.reduce((sum, stream) => sum + Number(stream.endTime - stream.startTime) / 86400, 0) / filtered.length - : 0 - - const tokenGroups = new Map() + { value: "7d", label: "7 days" }, + { value: "30d", label: "30 days" }, + { value: "90d", label: "90 days" }, + { value: "all", label: "All time" }, +] as const; + +function buildSnapshot( + streams: StreamData[], + range: string, +): AnalyticsSnapshot { + const now = Math.floor(Date.now() / 1000); + const cutoff = + range === "all" + ? 0 + : Date.now() - + Number.parseInt(range.replace("d", ""), 10) * 24 * 60 * 60 * 1000; + const filtered = streams.filter( + (stream) => Number(stream.startTime) * 1000 >= cutoff || range === "all", + ); + + const totalVolume = filtered.reduce( + (sum, stream) => sum + stream.depositedAmount, + 0n, + ); + const activeCount = filtered.filter( + (stream) => !stream.cancelled && Number(stream.endTime) > now, + ).length; + const totalStreams = filtered.length; + const averageDurationDays = + filtered.length > 0 + ? filtered.reduce( + (sum, stream) => + sum + Number(stream.endTime - stream.startTime) / 86400, + 0, + ) / filtered.length + : 0; + + const tokenGroups = new Map< + string, + { amount: bigint; count: number; decimals: number } + >(); filtered.forEach((stream) => { - const key = stream.token.symbol - const entry = tokenGroups.get(key) ?? { amount: 0n, count: 0, decimals: stream.token.decimals } - entry.amount += stream.depositedAmount - entry.count += 1 - tokenGroups.set(key, entry) - }) - - const tokenShares = Array.from(tokenGroups.entries()).map(([symbol, entry]) => ({ - symbol, - amount: entry.amount, - count: entry.count, - })) - - const seriesMap = new Map() + const key = stream.token.symbol; + const entry = tokenGroups.get(key) ?? { + amount: 0n, + count: 0, + decimals: stream.token.decimals, + }; + entry.amount += stream.depositedAmount; + entry.count += 1; + tokenGroups.set(key, entry); + }); + + const tokenShares = Array.from(tokenGroups.entries()).map( + ([symbol, entry]) => ({ + symbol, + amount: entry.amount, + count: entry.count, + }), + ); + + const seriesMap = new Map(); filtered.forEach((stream) => { - const day = new Date(Number(stream.startTime) * 1000).toISOString().slice(0, 10) - seriesMap.set(day, (seriesMap.get(day) ?? 0) + 1) - }) + const day = new Date(Number(stream.startTime) * 1000) + .toISOString() + .slice(0, 10); + seriesMap.set(day, (seriesMap.get(day) ?? 0) + 1); + }); const series = Array.from(seriesMap.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([label, count]) => ({ label, count })) + .map(([label, count]) => ({ label, count })); - const topTokens = [...tokenShares].sort((a, b) => Number(b.amount - a.amount)).slice(0, 4) + const topTokens = [...tokenShares] + .sort((a, b) => Number(b.amount - a.amount)) + .slice(0, 4); return { totalVolume, @@ -100,36 +151,46 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot tokenShares, series, topTokens, - } + }; } export default function AnalyticsPage() { - const { all } = useStreams({ enablePolling: false }) - const { network } = useNetwork() - const [range, setRange] = useState('30d') - const [mounted, setMounted] = useState(false) + const { all } = useStreams({ enablePolling: false }); + const { network } = useNetwork(); + const [range, setRange] = useState("30d"); + const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []) + useEffect(() => setMounted(true), []); - const snapshot = useMemo(() => buildSnapshot(all, range), [all, range]) + const snapshot = useMemo(() => buildSnapshot(all, range), [all, range]); - const tokens = useMemo(() => getAllTokens(network), [network]) + const tokens = useMemo(() => getAllTokens(network), [network]); - if (!mounted) return null + if (!mounted) return null; return (
- + Back to dashboard -

Platform analytics

-

Public signals that highlight traction, usage, and stream growth.

+

+ Platform analytics +

+

+ Public signals that highlight traction, usage, and stream growth. +

- v !== null && setRange(v)} + > @@ -148,7 +209,11 @@ export default function AnalyticsPage() { Total volume streamed - {snapshot.totalVolume > 0n ? formatCompactAmount(snapshot.totalVolume, 7) : '0'} + + {snapshot.totalVolume > 0n + ? formatCompactAmount(snapshot.totalVolume, 7) + : "0"} + Across the visible stream history @@ -157,7 +222,9 @@ export default function AnalyticsPage() { Active streams - {snapshot.activeCount} + + {snapshot.activeCount} + Currently streaming now @@ -166,7 +233,9 @@ export default function AnalyticsPage() { Total streams created - {snapshot.totalStreams} + + {snapshot.totalStreams} + All-time stream count @@ -175,7 +244,9 @@ export default function AnalyticsPage() { Average duration - {snapshot.averageDurationDays.toFixed(1)}d + + {snapshot.averageDurationDays.toFixed(1)}d + Average stream length @@ -195,17 +266,24 @@ export default function AnalyticsPage() { Network context - Current public view and available tokens. + + Current public view and available tokens. + -

This dashboard is built from the current app data and will be backed by on-chain aggregation once a public index is available.

+

+ This dashboard is built from the current app data and will be backed + by on-chain aggregation once a public index is available. +

{tokens.map((token) => ( - {token.symbol} + + {token.symbol} + ))}
- ) + ); } diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 118a4cb..9ddb611 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -1,42 +1,1226 @@ -'use client' - -import { useState, useCallback, useEffect, useRef } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { AlertTriangle, ArrowLeft, ArrowRight, Info, Loader2, Copy, Clock } from 'lucide-react' -import Link from 'next/link' -import { toast } from 'sonner' -import { StrKey } from '@stellar/stellar-sdk' -import { RequireWallet } from '@/components/layout/require-wallet' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + AlertTriangle, + ArrowLeft, + ArrowRight, + Info, + Loader2, + Copy, + Clock, +} from "lucide-react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { StrKey } from "@stellar/stellar-sdk"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select' -import { useContract } from '@/hooks/use-contract' -import { useWallet } from '@/hooks/use-wallet' -import { getAllTokens, saveCustomToken, checkAccountInfo } from '@/lib/stellar' -import { getTokenMetadata, getTokenBalance } from '@/lib/contract' -import { parseTokenAmount, formatTokenAmount } from '@/lib/stream-utils' -import { StreamPreview } from '@/components/streams/stream-preview' -import { CreateConfirmation } from '@/components/streams/create-confirmation' -import { TxPreviewDialog } from '@/components/ui/tx-preview-dialog' -import { addAddressBookEntry, getAddressBookEntries, touchAddressBookEntry } from '@/lib/address-book' -import { buildNextRunAt, saveRecurringRule, type RecurrenceCadence } from '@/lib/recurring' -import { useFormDraft, clearExpiredDrafts } from '@/hooks/use-form-draft' -import { StreamTemplates, type StreamTemplate } from '@/components/streams/stream-templates' -import type { TokenInfo } from '@/types/stream' - -// ... rest of the create form code from page.tsx -// This is a placeholder - the actual component is too large to copy here -// In a real implementation, you would move the entire CreateForm component here +} from "@/components/ui/select"; +import { useContract } from "@/hooks/use-contract"; +import { useWallet } from "@/hooks/use-wallet"; +import { getAllTokens, saveCustomToken, checkAccountInfo } from "@/lib/stellar"; +import { getTokenMetadata, getTokenBalance } from "@/lib/contract"; +import { parseTokenAmount, formatTokenAmount } from "@/lib/stream-utils"; +import { StreamPreview } from "@/components/streams/stream-preview"; +import { CreateConfirmation } from "@/components/streams/create-confirmation"; +import { TxPreviewDialog } from "@/components/ui/tx-preview-dialog"; +import { + addAddressBookEntry, + getAddressBookEntries, + touchAddressBookEntry, +} from "@/lib/address-book"; +import { + buildNextRunAt, + saveRecurringRule, + type RecurrenceCadence, +} from "@/lib/recurring"; +import { useFormDraft, clearExpiredDrafts } from "@/hooks/use-form-draft"; +import { + StreamTemplates, + type StreamTemplate, +} from "@/components/streams/stream-templates"; +import { useTokenPrice } from "@/hooks/use-token-price"; +import type { TokenInfo } from "@/types/stream"; +import { useNetwork } from "@/components/providers/network-provider"; + +const CUSTOM_VALUE = "__custom__"; + +function toUnixSeconds(localDatetimeValue: string): bigint { + return BigInt(Math.floor(new Date(localDatetimeValue).getTime() / 1000)); +} + +function localDatetimeMin(offsetSeconds = 0): string { + const d = new Date(Date.now() + offsetSeconds * 1000); + // Format as YYYY-MM-DDTHH:mm in local time (not UTC) + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function addDuration(baseDatetime: string, seconds: number): string { + const base = new Date(baseDatetime); + const d = new Date(base.getTime() + seconds * 1000); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function detectTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +function getTimezoneOffset(): string { + const offset = -new Date().getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + const h = Math.floor(Math.abs(offset) / 60); + const m = Math.abs(offset) % 60; + return `UTC${sign}${h}${m > 0 ? `:${String(m).padStart(2, "0")}` : ""}`; +} + +const COMMON_TIMEZONES = [ + "UTC", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Los_Angeles", + "America/Sao_Paulo", + "Europe/London", + "Europe/Paris", + "Europe/Berlin", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Singapore", + "Asia/Tokyo", + "Australia/Sydney", +] as const; + +const DURATION_PRESETS = [ + { label: "1 week", seconds: 7 * 24 * 3600 }, + { label: "1 month", seconds: 30 * 24 * 3600 }, + { label: "3 months", seconds: 90 * 24 * 3600 }, + { label: "6 months", seconds: 180 * 24 * 3600 }, + { label: "1 year", seconds: 365 * 24 * 3600 }, +] as const; + +const CLIFF_PRESETS = [ + { label: "No cliff", seconds: 0 }, + { label: "1 month", seconds: 30 * 24 * 3600 }, + { label: "3 months", seconds: 90 * 24 * 3600 }, +] as const; + +interface FormState { + recipient: string; + tokenAddress: string; + amount: string; + startDate: string; + endDate: string; + hasCliff: boolean; + cliffDate: string; + cliffAmount: string; +} export function CreateForm() { - // This would contain all the CreateForm logic from the original page.tsx - // For now, returning a placeholder to avoid breaking the build - return
Create Form Component
+ const router = useRouter(); + const searchParams = useSearchParams(); + const cloneId = searchParams.get("clone"); + const { address: walletAddress } = useWallet(); + const { network } = useNetwork(); + const { createStream, estimateFee, pending, error } = useContract(); + const [feeEstimate, setFeeEstimate] = useState(null); + const [estimatingFee, setEstimatingFee] = useState(false); + const [showTxPreview, setShowTxPreview] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); + + const [tokens, setTokens] = useState(() => + getAllTokens(network).map((t) => ({ ...t })), + ); + const [isCustom, setIsCustom] = useState(false); + const [customAddress, setCustomAddress] = useState(""); + const [customLoading, setCustomLoading] = useState(false); + const [customError, setCustomError] = useState(null); + const [customToken, setCustomToken] = useState(null); + const [addressBookEntries, setAddressBookEntries] = useState(() => + getAddressBookEntries(), + ); + const [recurrenceCadence, setRecurrenceCadence] = + useState("none"); + + // Issue #29: balance state + const [tokenBalance, setTokenBalance] = useState(null); + const [balanceLoading, setBalanceLoading] = useState(false); + + // Issue #103: recipient account validation + const [recipientAccountInfo, setRecipientAccountInfo] = useState<{ + exists: boolean; + funded: boolean; + transactionCount: number; + } | null>(null); + const [recipientChecking, setRecipientChecking] = useState(false); + const [recipientWarningAcknowledged, setRecipientWarningAcknowledged] = + useState(false); + + const defaultStart = localDatetimeMin(60); + const defaultEnd = localDatetimeMin(60 + 30 * 24 * 3600); + + const [form, setForm] = useState(() => { + const newStart = localDatetimeMin(60); + const durationSecs = searchParams.get("duration"); + const cliffSecs = searchParams.get("cliff"); + const hasCliff = cliffSecs !== null && cliffSecs !== "0"; + const newEnd = durationSecs + ? addDuration(newStart, Number(durationSecs)) + : localDatetimeMin(60 + 30 * 24 * 3600); + const newCliff = + hasCliff && cliffSecs + ? addDuration(newStart, Number(cliffSecs)) + : newStart; + + return { + recipient: searchParams.get("recipient") ?? "", + tokenAddress: searchParams.get("token") ?? tokens[0]?.address ?? "", + amount: searchParams.get("amount") ?? "", + startDate: newStart, + endDate: newEnd, + hasCliff, + cliffDate: newCliff, + cliffAmount: searchParams.get("cliffAmount") ?? "", + }; + }); + + const [errors, setErrors] = useState< + Partial> + >({}); + const [selectedTemplateId, setSelectedTemplateId] = useState< + string | undefined + >(undefined); + + // Issue #168: draft state + const [showDraftBanner, setShowDraftBanner] = useState(false); + const [draftSavedAt, setDraftSavedAt] = useState(null); + const isFirstMount = useRef(true); + + // Issue #170: timezone state + const [selectedTimezone, setSelectedTimezone] = useState(() => + detectTimezone(), + ); + const timezoneOffset = getTimezoneOffset(); + + const selectedToken = + isCustom && customToken + ? customToken + : (tokens.find((t) => t.address === form.tokenAddress) ?? tokens[0]); + + // Issue #186: USD conversion + const [usdInputMode, setUsdInputMode] = useState(false); + const [usdAmount, setUsdAmount] = useState(""); + const { + usdPrice, + stale: priceStale, + loading: priceLoading, + } = useTokenPrice(selectedToken?.symbol ?? ""); + + const supportsUsd = usdPrice !== null; + const tokenAmountNum = parseFloat(form.amount) || 0; + const usdEquivalent = + supportsUsd && tokenAmountNum > 0 + ? (tokenAmountNum * usdPrice).toFixed(2) + : null; + + const amountPerSecondUsd = + usdEquivalent && + (() => { + const dur = + (new Date(form.endDate).getTime() - + new Date(form.startDate).getTime()) / + 1000; + if (dur <= 0) return null; + const usdPerSec = parseFloat(usdEquivalent) / dur; + return usdPerSec < 0.01 + ? usdPerSec.toExponential(2) + : usdPerSec.toFixed(4); + })(); + + function handleUsdAmountChange(val: string) { + setUsdAmount(val); + if (usdPrice && val) { + const tokenAmt = parseFloat(val) / usdPrice; + if (!isNaN(tokenAmt)) + set("amount", tokenAmt.toFixed(selectedToken?.decimals ?? 7)); + } else { + set("amount", ""); + } + } + + // Fetch balance when token or wallet changes + useEffect(() => { + if (!walletAddress || !selectedToken) return; + setTokenBalance(null); + setBalanceLoading(true); + getTokenBalance(selectedToken.address, walletAddress) + .then(setTokenBalance) + .catch(() => setTokenBalance(null)) + .finally(() => setBalanceLoading(false)); + }, [selectedToken?.address, walletAddress]); + + // Issue #103: Check recipient account info when address changes + useEffect(() => { + const recipient = form.recipient.trim(); + if (!recipient || !StrKey.isValidEd25519PublicKey(recipient)) { + setRecipientAccountInfo(null); + setRecipientWarningAcknowledged(false); + return; + } + + setRecipientChecking(true); + checkAccountInfo(recipient, network) + .then((info) => { + setRecipientAccountInfo(info); + setRecipientWarningAcknowledged(false); + }) + .catch(() => { + setRecipientAccountInfo(null); + }) + .finally(() => { + setRecipientChecking(false); + }); + }, [form.recipient, network]); + + // Issue #168: wire up draft hook + const { loadDraft, restore, discard } = useFormDraft( + `create-stream-${walletAddress ?? "anonymous"}`, + form, + (draft) => { + setForm(draft); + }, + true, + ); + + // Check for existing draft on first mount + useEffect(() => { + if (!isFirstMount.current) return; + isFirstMount.current = false; + clearExpiredDrafts(); + const entry = loadDraft(); + if (entry) { + setDraftSavedAt(entry.savedAt); + setShowDraftBanner(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleCustomTokenLookup() { + if (!customAddress || customAddress.length < 56) { + setCustomError( + "Enter a valid Stellar contract address (56 chars, starts with C)", + ); + return; + } + setCustomLoading(true); + setCustomError(null); + setCustomToken(null); + try { + const meta = await getTokenMetadata(customAddress); + if (!meta) { + setCustomError( + "Could not fetch token metadata. Verify this is a valid SEP-41 token contract.", + ); + return; + } + setCustomToken(meta); + saveCustomToken(network, meta); + setTokens(getAllTokens(network).map((t) => ({ ...t }))); + set("tokenAddress", meta.address); + } catch { + setCustomError("Failed to query token contract"); + } finally { + setCustomLoading(false); + } + } + + function set(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + setErrors((prev) => ({ ...prev, [key]: undefined })); + } + + function validate(): boolean { + const newErrors: Partial> = {}; + + // Issue #28: use StrKey for proper Stellar address validation + if ( + !form.recipient.trim() || + !StrKey.isValidEd25519PublicKey(form.recipient.trim()) + ) { + newErrors.recipient = "Invalid Stellar address format"; + } + // Issue #103: require warning acknowledgment for unfunded accounts + if ( + recipientAccountInfo && + !recipientAccountInfo.exists && + !recipientWarningAcknowledged + ) { + newErrors.recipient = + "Please acknowledge the warning about this recipient address"; + } + if ( + !form.amount || + isNaN(Number(form.amount)) || + Number(form.amount) <= 0 + ) { + newErrors.amount = "Enter a valid amount greater than 0"; + } + // Issue #29: validate against balance + if (form.amount && tokenBalance !== null) { + const parsed = parseTokenAmount(form.amount, selectedToken.decimals); + if (parsed > tokenBalance) { + newErrors.amount = `Amount exceeds your balance (${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol})`; + } + } + if (isCustom && !customToken) { + newErrors.tokenAddress = "Look up a valid custom token first"; + } + const start = new Date(form.startDate).getTime(); + const end = new Date(form.endDate).getTime(); + if (!form.endDate || end <= start) { + newErrors.endDate = "End date must be after start date"; + } + if (form.hasCliff) { + const cliff = new Date(form.cliffDate).getTime(); + if (!form.cliffDate || cliff < start || cliff > end) { + newErrors.cliffDate = "Cliff must be between start and end date"; + } + if (form.cliffAmount && Number(form.cliffAmount) > Number(form.amount)) { + newErrors.cliffAmount = "Cliff amount cannot exceed total amount"; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + } + + const buildInput = useCallback(() => { + const startTime = toUnixSeconds(form.startDate); + const endTime = toUnixSeconds(form.endDate); + const cliffTime = form.hasCliff ? toUnixSeconds(form.cliffDate) : startTime; + const cliffAmount = + form.hasCliff && form.cliffAmount + ? parseTokenAmount(form.cliffAmount, selectedToken.decimals) + : 0n; + return { + recipient: form.recipient.trim(), + token: selectedToken, + totalAmount: parseTokenAmount(form.amount, selectedToken.decimals), + startTime, + endTime, + cliffTime, + cliffAmount, + }; + }, [form, selectedToken]); + + async function handleEstimateFee() { + if (!validate()) return; + setEstimatingFee(true); + setFeeEstimate(null); + try { + const estimate = await estimateFee(buildInput()); + if (estimate) { + setFeeEstimate(estimate.estimatedFeeXlm); + } + } catch { + setFeeEstimate(null); + } finally { + setEstimatingFee(false); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + // Show tx simulation preview first; user proceeds to Freighter from there. + setShowTxPreview(true); + } + + async function handleConfirmedCreate() { + try { + const input = buildInput(); + const id = await createStream(input); + touchAddressBookEntry(input.recipient, input.recipient); + setAddressBookEntries(getAddressBookEntries()); + + if (recurrenceCadence !== "none") { + saveRecurringRule({ + cadence: recurrenceCadence, + nextRunAt: buildNextRunAt(Date.now(), recurrenceCadence), + lastCreatedAt: Date.now(), + streamId: id, + recipient: input.recipient, + tokenSymbol: input.token.symbol, + amount: input.totalAmount.toString(), + }); + } + + discard(); + setShowConfirmation(false); + toast.success("Stream created", { + description: `Stream #${id} is live.`, + }); + router.push(`/app/stream/${id}`); + } catch { + // error is exposed via useContract + } + } + + function handleTemplateSelect(template: StreamTemplate) { + setSelectedTemplateId(template.id); + const newStart = localDatetimeMin(60); + const newEnd = addDuration(newStart, template.durationSeconds); + const hasCliff = template.cliffSeconds > 0; + const newCliff = hasCliff + ? addDuration(newStart, template.cliffSeconds) + : newStart; + + setForm((prev) => { + const amount = prev.amount; + const cliffAmount = + hasCliff && template.cliffPercent > 0 && amount + ? String(Math.floor((Number(amount) * template.cliffPercent) / 100)) + : ""; + return { + ...prev, + startDate: newStart, + endDate: newEnd, + hasCliff, + cliffDate: newCliff, + cliffAmount, + }; + }); + setErrors({}); + } + + const input = showTxPreview || showConfirmation ? buildInput() : null; + const durationSeconds = + (new Date(form.endDate).getTime() - new Date(form.startDate).getTime()) / + 1000; + const amountPerSecond = + input && durationSeconds > 0 + ? input.totalAmount / BigInt(Math.floor(durationSeconds)) + : 0n; + + return ( +
+ {/* Back */} + + + Back to dashboard + + +
+

+ Create a stream +

+

+ Tokens unlock continuously to the recipient from start to end. +

+
+ +
+ +
+ {cloneId && ( +
+ +

+ Duplicating Stream #{cloneId} — form pre-filled with its parameters. +

+
+ )} + + {/* Issue #168: Draft restore banner */} + {showDraftBanner && ( +
+
+ +

+ You have an unsaved draft from{" "} + {draftSavedAt + ? new Date(draftSavedAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : "earlier"} + . +

+
+
+ + +
+
+ )} + +
+
+ {/* Token + Amount */} +
+

+ Amount +

+ +
+ + +
+ + {isCustom && ( +
+ +
+ { + setCustomAddress(e.target.value); + setCustomError(null); + }} + className="font-mono text-xs" + /> + +
+ {customError && ( +

{customError}

+ )} + {customToken && ( +

+ Found: {customToken.symbol} ({customToken.decimals}{" "} + decimals) +

+ )} +
+ )} + +
+ {/* Issue #29: show balance + Max button */} +
+ + + {balanceLoading + ? "Loading balance…" + : tokenBalance !== null + ? `Balance: ${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol}` + : null} + +
+ + {/* Issue #186: USD/token mode toggle */} + {supportsUsd && ( +
+ + + {priceStale && ( + Price may be stale + )} +
+ )} + +
+ {usdInputMode && supportsUsd ? ( +
+ + $ + + handleUsdAmountChange(e.target.value)} + aria-invalid={!!errors.amount} + /> +
+ ) : ( + set("amount", e.target.value)} + aria-invalid={!!errors.amount} + /> + )} + {tokenBalance !== null && tokenBalance > 0n && ( + + )} +
+ + {/* Issue #186: USD equivalent + per-second rate */} + {usdEquivalent && !usdInputMode && ( +

+ ≈ ${usdEquivalent} USD + {amountPerSecondUsd && ( + + · Streaming rate:{" "} + {( + tokenAmountNum / + Math.max( + 1, + (new Date(form.endDate).getTime() - + new Date(form.startDate).getTime()) / + 1000, + ) + ).toFixed(6)}{" "} + {selectedToken.symbol}/sec (≈ ${amountPerSecondUsd}/sec) + + )} + {priceLoading && ( + Fetching price… + )} +

+ )} + {usdInputMode && form.amount && ( +

+ ≈ {form.amount} {selectedToken.symbol} +

+ )} + + {errors.amount && ( +

{errors.amount}

+ )} +
+
+ + {/* Recipient */} +
+

+ Recipient +

+
+ + set("recipient", e.target.value)} + aria-invalid={!!errors.recipient} + className="font-mono text-xs" + /> +
+ +
+ {addressBookEntries.length > 0 && ( +
+

+ Recent recipients +

+
+ {addressBookEntries.slice(0, 6).map((entry) => ( + + ))} +
+
+ )} + {errors.recipient && ( +

{errors.recipient}

+ )} + {walletAddress && form.recipient.trim() === walletAddress && ( +
+ + + This is your own address. Self-streams are allowed but may + have been unintended. + +
+ )} + {/* Issue #103: Warning for unfunded/unknown recipient accounts */} + {recipientAccountInfo && !recipientAccountInfo.exists && ( +
+ +
+

+ This address has no transaction history. +

+

+ Tokens sent to this address may be unrecoverable if it's + not funded. +

+ +
+
+ )} + {recipientChecking && ( +
+ + Checking recipient account... +
+ )} +
+
+ + {/* Schedule */} +
+

+ Schedule +

+ + {/* Issue #170: timezone selector */} +
+ + +
+ +
+
+ + set("startDate", e.target.value)} + /> +
+
+ + set("endDate", e.target.value)} + aria-invalid={!!errors.endDate} + /> + {errors.endDate && ( +

{errors.endDate}

+ )} +
+
+ + {/* Duration presets */} +
+ +
+ {DURATION_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Cliff toggle */} +
+ set("hasCliff", e.target.checked)} + className="mt-0.5 size-4 accent-primary" + /> +
+ +

+ Nothing unlocks before the cliff date. Optionally release a + lump sum at the cliff. +

+
+
+ +
+ + +

+ Save a renewal rule for this recipient so the schedule can be + recreated later. +

+
+ + {form.hasCliff && ( +
+ {/* Cliff presets */} +
+ +
+ {CLIFF_PRESETS.map((preset) => ( + + ))} +
+
+
+
+ + set("cliffDate", e.target.value)} + aria-invalid={!!errors.cliffDate} + /> + {errors.cliffDate && ( +

+ {errors.cliffDate} +

+ )} +
+
+ + set("cliffAmount", e.target.value)} + aria-invalid={!!errors.cliffAmount} + /> + {errors.cliffAmount && ( +

+ {errors.cliffAmount} +

+ )} +
+
+
+ )} +
+ + {network === "mainnet" && ( +
+ + + Mainnet uses real funds. Double-check the recipient, amount, and + token before creating a stream. + +
+ )} + + {/* Contract error */} + {error && ( +
+ + {error} +
+ )} + + {/* Fee estimate */} + {feeEstimate && ( +
+ + Estimated transaction fee: ~{feeEstimate} XLM (includes 15% + buffer) +
+ )} + + {/* Submit */} +
+ + + +
+
+ + {/* Live preview sidebar */} + +
+ + {/* Step 1: dry-run simulation preview */} + {input && ( + { + setShowTxPreview(false); + setShowConfirmation(true); + }} + onCancel={() => setShowTxPreview(false)} + pending={false} + /> + )} + + {/* Step 2: confirmation + fee details → Freighter signing */} + {input && ( + setShowConfirmation(false)} + pending={pending} + feeEstimate={feeEstimate} + recipient={input.recipient} + token={input.token} + totalAmount={input.totalAmount} + startTime={input.startTime} + endTime={input.endTime} + cliffTime={input.cliffTime} + cliffAmount={input.cliffAmount} + amountPerSecond={amountPerSecond} + /> + )} +
+ ); } diff --git a/app/app/create/page.tsx b/app/app/create/page.tsx index 7ddf867..afe7ac8 100644 --- a/app/app/create/page.tsx +++ b/app/app/create/page.tsx @@ -1,1106 +1,17 @@ -import { Metadata } from 'next' -import { useState, useCallback, useEffect, useRef } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { AlertTriangle, ArrowLeft, ArrowRight, Info, Loader2, Copy, Clock } from 'lucide-react' -import Link from 'next/link' -import { toast } from 'sonner' -import { StrKey } from '@stellar/stellar-sdk' -import { RequireWallet } from '@/components/layout/require-wallet' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { useContract } from '@/hooks/use-contract' -import { useWallet } from '@/hooks/use-wallet' -import { getAllTokens, saveCustomToken, checkAccountInfo } from '@/lib/stellar' -import { getTokenMetadata, getTokenBalance } from '@/lib/contract' -import { parseTokenAmount, formatTokenAmount } from '@/lib/stream-utils' -import { StreamPreview } from '@/components/streams/stream-preview' -import { CreateConfirmation } from '@/components/streams/create-confirmation' -import { TxPreviewDialog } from '@/components/ui/tx-preview-dialog' -import { addAddressBookEntry, getAddressBookEntries, touchAddressBookEntry } from '@/lib/address-book' -import { buildNextRunAt, saveRecurringRule, type RecurrenceCadence } from '@/lib/recurring' -import { useFormDraft, clearExpiredDrafts } from '@/hooks/use-form-draft' -import { StreamTemplates, type StreamTemplate } from '@/components/streams/stream-templates' -import { useTokenPrice } from '@/hooks/use-token-price' -import type { TokenInfo } from '@/types/stream' -import { useNetwork } from '@/components/providers/network-provider' -export const metadata: Metadata = { - title: 'Create a Token Stream', - description: 'Create a new token stream on FlowStar with customizable schedules, cliffs, and cancellation options.', -} - -const CUSTOM_VALUE = '__custom__' - -function toUnixSeconds(localDatetimeValue: string): bigint { - return BigInt(Math.floor(new Date(localDatetimeValue).getTime() / 1000)) -} - -function localDatetimeMin(offsetSeconds = 0): string { - const d = new Date(Date.now() + offsetSeconds * 1000) - // Format as YYYY-MM-DDTHH:mm in local time (not UTC) - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` -} - -function addDuration(baseDatetime: string, seconds: number): string { - const base = new Date(baseDatetime) - const d = new Date(base.getTime() + seconds * 1000) - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` -} - -function detectTimezone(): string { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone - } catch { - return 'UTC' - } -} - -function getTimezoneOffset(): string { - const offset = -new Date().getTimezoneOffset() - const sign = offset >= 0 ? '+' : '-' - const h = Math.floor(Math.abs(offset) / 60) - const m = Math.abs(offset) % 60 - return `UTC${sign}${h}${m > 0 ? `:${String(m).padStart(2, '0')}` : ''}` -} - -const COMMON_TIMEZONES = [ - 'UTC', - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'America/Sao_Paulo', - 'Europe/London', - 'Europe/Paris', - 'Europe/Berlin', - 'Asia/Dubai', - 'Asia/Kolkata', - 'Asia/Singapore', - 'Asia/Tokyo', - 'Australia/Sydney', -] as const - -const DURATION_PRESETS = [ - { label: '1 week', seconds: 7 * 24 * 3600 }, - { label: '1 month', seconds: 30 * 24 * 3600 }, - { label: '3 months', seconds: 90 * 24 * 3600 }, - { label: '6 months', seconds: 180 * 24 * 3600 }, - { label: '1 year', seconds: 365 * 24 * 3600 }, -] as const - -const CLIFF_PRESETS = [ - { label: 'No cliff', seconds: 0 }, - { label: '1 month', seconds: 30 * 24 * 3600 }, - { label: '3 months', seconds: 90 * 24 * 3600 }, -] as const - -interface FormState { - recipient: string - tokenAddress: string - amount: string - startDate: string - endDate: string - hasCliff: boolean - cliffDate: string - cliffAmount: string -} - -function CreateForm() { - const router = useRouter() - const searchParams = useSearchParams() - const cloneId = searchParams.get('clone') - const { address: walletAddress } = useWallet() - const { network } = useNetwork() - const { createStream, estimateFee, pending, error } = useContract() - const [feeEstimate, setFeeEstimate] = useState(null) - const [estimatingFee, setEstimatingFee] = useState(false) - const [showTxPreview, setShowTxPreview] = useState(false) - const [showConfirmation, setShowConfirmation] = useState(false) - - const [tokens, setTokens] = useState(() => getAllTokens(network).map((t) => ({ ...t }))) - const [isCustom, setIsCustom] = useState(false) - const [customAddress, setCustomAddress] = useState('') - const [customLoading, setCustomLoading] = useState(false) - const [customError, setCustomError] = useState(null) - const [customToken, setCustomToken] = useState(null) - const [addressBookEntries, setAddressBookEntries] = useState(() => getAddressBookEntries()) - const [recurrenceCadence, setRecurrenceCadence] = useState('none') - - // Issue #166: stream metadata - const [metaName, setMetaName] = useState('') - const [metaCategory, setMetaCategory] = useState('') - const [metaMemo, setMetaMemo] = useState('') - - // Issue #29: balance state - const [tokenBalance, setTokenBalance] = useState(null) - const [balanceLoading, setBalanceLoading] = useState(false) - - // Issue #103: recipient account validation - const [recipientAccountInfo, setRecipientAccountInfo] = useState<{ exists: boolean; funded: boolean; transactionCount: number } | null>(null) - const [recipientChecking, setRecipientChecking] = useState(false) - const [recipientWarningAcknowledged, setRecipientWarningAcknowledged] = useState(false) - - const defaultStart = localDatetimeMin(60) - const defaultEnd = localDatetimeMin(60 + 30 * 24 * 3600) - - const [form, setForm] = useState(() => { - const newStart = localDatetimeMin(60) - const durationSecs = searchParams.get('duration') - const cliffSecs = searchParams.get('cliff') - const hasCliff = cliffSecs !== null && cliffSecs !== '0' - const newEnd = durationSecs - ? addDuration(newStart, Number(durationSecs)) - : localDatetimeMin(60 + 30 * 24 * 3600) - const newCliff = hasCliff && cliffSecs - ? addDuration(newStart, Number(cliffSecs)) - : newStart - - return { - recipient: searchParams.get('recipient') ?? '', - tokenAddress: searchParams.get('token') ?? (tokens[0]?.address ?? ''), - amount: searchParams.get('amount') ?? '', - startDate: newStart, - endDate: newEnd, - hasCliff, - cliffDate: newCliff, - cliffAmount: searchParams.get('cliffAmount') ?? '', - } - }) - - const [errors, setErrors] = useState>>({}) - const [selectedTemplateId, setSelectedTemplateId] = useState(undefined) - - // Issue #168: draft state - const [showDraftBanner, setShowDraftBanner] = useState(false) - const [draftSavedAt, setDraftSavedAt] = useState(null) - const isFirstMount = useRef(true) - - // Issue #170: timezone state - const [selectedTimezone, setSelectedTimezone] = useState(() => detectTimezone()) - const timezoneOffset = getTimezoneOffset() - - const selectedToken = isCustom && customToken - ? customToken - : tokens.find((t) => t.address === form.tokenAddress) ?? tokens[0] - - // Issue #186: USD conversion - const [usdInputMode, setUsdInputMode] = useState(false) - const [usdAmount, setUsdAmount] = useState('') - const { usdPrice, stale: priceStale, loading: priceLoading } = useTokenPrice(selectedToken?.symbol ?? '') - - const supportsUsd = usdPrice !== null - const tokenAmountNum = parseFloat(form.amount) || 0 - const usdEquivalent = supportsUsd && tokenAmountNum > 0 - ? (tokenAmountNum * usdPrice).toFixed(2) - : null - - const amountPerSecondUsd = usdEquivalent && (() => { - const dur = (new Date(form.endDate).getTime() - new Date(form.startDate).getTime()) / 1000 - if (dur <= 0) return null - const usdPerSec = (parseFloat(usdEquivalent) / dur) - return usdPerSec < 0.01 ? usdPerSec.toExponential(2) : usdPerSec.toFixed(4) - })() - - function handleUsdAmountChange(val: string) { - setUsdAmount(val) - if (usdPrice && val) { - const tokenAmt = parseFloat(val) / usdPrice - if (!isNaN(tokenAmt)) set('amount', tokenAmt.toFixed(selectedToken?.decimals ?? 7)) - } else { - set('amount', '') - } - } - - // Fetch balance when token or wallet changes - useEffect(() => { - if (!walletAddress || !selectedToken) return - setTokenBalance(null) - setBalanceLoading(true) - getTokenBalance(selectedToken.address, walletAddress) - .then(setTokenBalance) - .catch(() => setTokenBalance(null)) - .finally(() => setBalanceLoading(false)) - }, [selectedToken?.address, walletAddress]) - - // Issue #103: Check recipient account info when address changes - useEffect(() => { - const recipient = form.recipient.trim() - if (!recipient || !StrKey.isValidEd25519PublicKey(recipient)) { - setRecipientAccountInfo(null) - setRecipientWarningAcknowledged(false) - return - } - - setRecipientChecking(true) - checkAccountInfo(recipient, network) - .then((info) => { - setRecipientAccountInfo(info) - setRecipientWarningAcknowledged(false) - }) - .catch(() => { - setRecipientAccountInfo(null) - }) - .finally(() => { - setRecipientChecking(false) - }) - }, [form.recipient, network]) - - // Issue #168: wire up draft hook - const { loadDraft, restore, discard } = useFormDraft( - `create-stream-${walletAddress ?? 'anonymous'}`, - form, - (draft) => { - setForm(draft) - }, - true, - ) - - // Check for existing draft on first mount - useEffect(() => { - if (!isFirstMount.current) return - isFirstMount.current = false - clearExpiredDrafts() - const entry = loadDraft() - if (entry) { - setDraftSavedAt(entry.savedAt) - setShowDraftBanner(true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - async function handleCustomTokenLookup() { - if (!customAddress || customAddress.length < 56) { - setCustomError('Enter a valid Stellar contract address (56 chars, starts with C)') - return - } - setCustomLoading(true) - setCustomError(null) - setCustomToken(null) - try { - const meta = await getTokenMetadata(customAddress) - if (!meta) { - setCustomError('Could not fetch token metadata. Verify this is a valid SEP-41 token contract.') - return - } - setCustomToken(meta) - saveCustomToken(network, meta) - setTokens(getAllTokens(network).map((t) => ({ ...t }))) - set('tokenAddress', meta.address) - } catch { - setCustomError('Failed to query token contract') - } finally { - setCustomLoading(false) - } - } - - function set(key: K, value: FormState[K]) { - setForm((prev) => ({ ...prev, [key]: value })) - setErrors((prev) => ({ ...prev, [key]: undefined })) - } - - function validate(): boolean { - const newErrors: Partial> = {} +import { Metadata } from "next"; +import { RequireWallet } from "@/components/layout/require-wallet"; +import { CreateForm } from "./create-form"; - // Issue #28: use StrKey for proper Stellar address validation - if (!form.recipient.trim() || !StrKey.isValidEd25519PublicKey(form.recipient.trim())) { - newErrors.recipient = 'Invalid Stellar address format' - } - // Issue #103: require warning acknowledgment for unfunded accounts - if (recipientAccountInfo && !recipientAccountInfo.exists && !recipientWarningAcknowledged) { - newErrors.recipient = 'Please acknowledge the warning about this recipient address' - } - if (!form.amount || isNaN(Number(form.amount)) || Number(form.amount) <= 0) { - newErrors.amount = 'Enter a valid amount greater than 0' - } - // Issue #29: validate against balance - if (form.amount && tokenBalance !== null) { - const parsed = parseTokenAmount(form.amount, selectedToken.decimals) - if (parsed > tokenBalance) { - newErrors.amount = `Amount exceeds your balance (${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol})` - } - } - if (isCustom && !customToken) { - newErrors.tokenAddress = 'Look up a valid custom token first' - } - const start = new Date(form.startDate).getTime() - const end = new Date(form.endDate).getTime() - if (!form.endDate || end <= start) { - newErrors.endDate = 'End date must be after start date' - } - if (form.hasCliff) { - const cliff = new Date(form.cliffDate).getTime() - if (!form.cliffDate || cliff < start || cliff > end) { - newErrors.cliffDate = 'Cliff must be between start and end date' - } - if (form.cliffAmount && Number(form.cliffAmount) > Number(form.amount)) { - newErrors.cliffAmount = 'Cliff amount cannot exceed total amount' - } - } - - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - } - - const buildInput = useCallback(() => { - const startTime = toUnixSeconds(form.startDate) - const endTime = toUnixSeconds(form.endDate) - const cliffTime = form.hasCliff ? toUnixSeconds(form.cliffDate) : startTime - const cliffAmount = form.hasCliff && form.cliffAmount - ? parseTokenAmount(form.cliffAmount, selectedToken.decimals) - : 0n - const metadata = metaName || metaCategory || metaMemo - ? { name: metaName, category: metaCategory, memo: metaMemo } - : undefined - return { - recipient: form.recipient.trim(), - token: selectedToken, - totalAmount: parseTokenAmount(form.amount, selectedToken.decimals), - startTime, - endTime, - cliffTime, - cliffAmount, - metadata, - } - }, [form, selectedToken, metaName, metaCategory, metaMemo]) - - async function handleEstimateFee() { - if (!validate()) return - setEstimatingFee(true) - setFeeEstimate(null) - try { - const estimate = await estimateFee(buildInput()) - if (estimate) { - setFeeEstimate(estimate.estimatedFeeXlm) - } - } catch { - setFeeEstimate(null) - } finally { - setEstimatingFee(false) - } - } - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!validate()) return - // Show tx simulation preview first; user proceeds to Freighter from there. - setShowTxPreview(true) - } - - async function handleConfirmedCreate() { - try { - const input = buildInput() - const id = await createStream(input) - touchAddressBookEntry(input.recipient, input.recipient) - setAddressBookEntries(getAddressBookEntries()) - - if (recurrenceCadence !== 'none') { - saveRecurringRule({ - cadence: recurrenceCadence, - nextRunAt: buildNextRunAt(Date.now(), recurrenceCadence), - lastCreatedAt: Date.now(), - streamId: id, - recipient: input.recipient, - tokenSymbol: input.token.symbol, - amount: input.totalAmount.toString(), - }) - } - - discard() - setShowConfirmation(false) - toast.success('Stream created', { description: `Stream #${id} is live.` }) - router.push(`/app/stream/${id}`) - } catch { - // error is exposed via useContract - } - } - - function handleTemplateSelect(template: StreamTemplate) { - setSelectedTemplateId(template.id) - const newStart = localDatetimeMin(60) - const newEnd = addDuration(newStart, template.durationSeconds) - const hasCliff = template.cliffSeconds > 0 - const newCliff = hasCliff ? addDuration(newStart, template.cliffSeconds) : newStart - - setForm((prev) => { - const amount = prev.amount - const cliffAmount = hasCliff && template.cliffPercent > 0 && amount - ? String(Math.floor(Number(amount) * template.cliffPercent / 100)) - : '' - return { - ...prev, - startDate: newStart, - endDate: newEnd, - hasCliff, - cliffDate: newCliff, - cliffAmount, - } - }) - setErrors({}) - } - - const input = (showTxPreview || showConfirmation) ? buildInput() : null - const durationSeconds = (new Date(form.endDate).getTime() - new Date(form.startDate).getTime()) / 1000 - const amountPerSecond = input && durationSeconds > 0 - ? input.totalAmount / BigInt(Math.floor(durationSeconds)) - : 0n - - return ( -
- {/* Back */} - - - Back to dashboard - - -
-

Create a stream

-

- Tokens unlock continuously to the recipient from start to end. -

-
- -
- -
- {cloneId && ( -
- -

- Duplicating Stream #{cloneId} — form pre-filled with its parameters. -

-
- )} - - {/* Issue #168: Draft restore banner */} - {showDraftBanner && ( -
-
- -

- You have an unsaved draft from{' '} - {draftSavedAt - ? new Date(draftSavedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : 'earlier'} - . -

-
-
- - -
-
- )} - -
-
- {/* Token + Amount */} -
-

- Amount -

- -
- - -
- - {isCustom && ( -
- -
- { - setCustomAddress(e.target.value) - setCustomError(null) - }} - className="font-mono text-xs" - /> - -
- {customError && ( -

{customError}

- )} - {customToken && ( -

- Found: {customToken.symbol} ({customToken.decimals} decimals) -

- )} -
- )} - -
- {/* Issue #29: show balance + Max button */} -
- - - {balanceLoading - ? 'Loading balance…' - : tokenBalance !== null - ? `Balance: ${formatTokenAmount(tokenBalance, selectedToken.decimals, 4)} ${selectedToken.symbol}` - : null} - -
- - {/* Issue #186: USD/token mode toggle */} - {supportsUsd && ( -
- - - {priceStale && Price may be stale} -
- )} - -
- {usdInputMode && supportsUsd ? ( -
- $ - handleUsdAmountChange(e.target.value)} - aria-invalid={!!errors.amount} - /> -
- ) : ( - set('amount', e.target.value)} - aria-invalid={!!errors.amount} - /> - )} - {tokenBalance !== null && tokenBalance > 0n && ( - - )} -
- - {/* Issue #186: USD equivalent + per-second rate */} - {usdEquivalent && !usdInputMode && ( -

- ≈ ${usdEquivalent} USD - {amountPerSecondUsd && ( - - · Streaming rate: {(tokenAmountNum / Math.max(1, (new Date(form.endDate).getTime() - new Date(form.startDate).getTime()) / 1000)).toFixed(6)} {selectedToken.symbol}/sec (≈ ${amountPerSecondUsd}/sec) - - )} - {priceLoading && Fetching price…} -

- )} - {usdInputMode && form.amount && ( -

- ≈ {form.amount} {selectedToken.symbol} -

- )} - - {errors.amount && ( -

{errors.amount}

- )} -
-
- - {/* Recipient */} -
-

- Recipient -

-
- - set('recipient', e.target.value)} - aria-invalid={!!errors.recipient} - className="font-mono text-xs" - /> -
- -
- {addressBookEntries.length > 0 && ( -
-

Recent recipients

-
- {addressBookEntries.slice(0, 6).map((entry) => ( - - ))} -
-
- )} - {errors.recipient && ( -

{errors.recipient}

- )} - {walletAddress && form.recipient.trim() === walletAddress && ( -
- - This is your own address. Self-streams are allowed but may have been unintended. -
- )} - {/* Issue #103: Warning for unfunded/unknown recipient accounts */} - {recipientAccountInfo && !recipientAccountInfo.exists && ( -
- -
-

This address has no transaction history.

-

Tokens sent to this address may be unrecoverable if it's not funded.

- -
-
- )} - {recipientChecking && ( -
- - Checking recipient account... -
- )} -
-
- - {/* Schedule */} -
-

- Schedule -

- - {/* Issue #170: timezone selector */} -
- - -
- -
-
- - set('startDate', e.target.value)} - /> -
-
- - set('endDate', e.target.value)} - aria-invalid={!!errors.endDate} - /> - {errors.endDate && ( -

{errors.endDate}

- )} -
-
- - {/* Duration presets */} -
- -
- {DURATION_PRESETS.map((preset) => ( - - ))} -
-
- - {/* Cliff toggle */} -
- set('hasCliff', e.target.checked)} - className="mt-0.5 size-4 accent-primary" - /> -
- -

- Nothing unlocks before the cliff date. Optionally release a lump sum at the cliff. -

-
-
- -
- - -

- Save a renewal rule for this recipient so the schedule can be recreated later. -

-
- - {form.hasCliff && ( -
- {/* Cliff presets */} -
- -
- {CLIFF_PRESETS.map((preset) => ( - - ))} -
-
-
-
- - set('cliffDate', e.target.value)} - aria-invalid={!!errors.cliffDate} - /> - {errors.cliffDate && ( -

{errors.cliffDate}

- )} -
-
- - set('cliffAmount', e.target.value)} - aria-invalid={!!errors.cliffAmount} - /> - {errors.cliffAmount && ( -

{errors.cliffAmount}

- )} -
-
-
- )} -
- - {/* Optional metadata */} -
-

Stream metadata (optional)

-
-
- - setMetaName(e.target.value.slice(0, 64))} - maxLength={64} - /> -
-
- - -
-
-
- - setMetaMemo(e.target.value.slice(0, 256))} - maxLength={256} - /> -
-
- - {network === 'mainnet' && (
- - Mainnet uses real funds. Double-check the recipient, amount, and token before creating a stream. -
- )} - - {/* Contract error */} - {error && ( -
- - {error} -
- )} - - {/* Fee estimate */} - {feeEstimate && ( -
- - Estimated transaction fee: ~{feeEstimate} XLM (includes 15% buffer) -
- )} - - {/* Submit */} -
- - - -
-
- - {/* Live preview sidebar */} - -
- - {/* Step 1: dry-run simulation preview */} - {input && ( - { setShowTxPreview(false); setShowConfirmation(true) }} - onCancel={() => setShowTxPreview(false)} - pending={false} - /> - )} - - {/* Step 2: confirmation + fee details → Freighter signing */} - {input && ( - setShowConfirmation(false)} - pending={pending} - feeEstimate={feeEstimate} - recipient={input.recipient} - token={input.token} - totalAmount={input.totalAmount} - startTime={input.startTime} - endTime={input.endTime} - cliffTime={input.cliffTime} - cliffAmount={input.cliffAmount} - amountPerSecond={amountPerSecond} - /> - )} -
- ) -} +export const metadata: Metadata = { + title: "Create a Token Stream", + description: + "Create a new token stream on FlowStar with customizable schedules, cliffs, and cancellation options.", +}; export default function CreatePage() { return ( - ) -} \ No newline at end of file + ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx index 0eec55a..47773ef 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,486 +1,17 @@ -import { Metadata } from 'next' -import { RequireWallet } from '@/components/layout/require-wallet' -import { Dashboard } from './dashboard' +import { Metadata } from "next"; +import { RequireWallet } from "@/components/layout/require-wallet"; +import { Dashboard } from "./dashboard"; export const metadata: Metadata = { - title: 'Your Streams', - description: 'View and manage your active and historical token streams on FlowStar.', -'use client' - -import Link from 'next/link' -import { useState, useMemo, Suspense } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import { Plus, ArrowDownToLine, Search, X } from 'lucide-react' -import { RequireWallet } from '@/components/layout/require-wallet' -import { DashboardStats, DashboardStatsSkeleton } from '@/components/streams/dashboard-stats' -import { StreamCard, StreamCardSkeleton } from '@/components/streams/stream-card' -import { EmptyStreams } from '@/components/streams/empty-state' -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Badge } from '@/components/ui/badge' -import { useStreams } from '@/hooks/use-streams' -import { useContract } from '@/hooks/use-contract' -import { useNow } from '@/hooks/use-now' -import { getWithdrawableAmount, getStreamStatus, getStreamProgress } from '@/lib/stream-utils' -import type { StreamData, StreamStatus } from '@/types/stream' - -// ─── Filter / sort types ────────────────────────────────────────────────────── - -type SortKey = 'newest' | 'oldest' | 'amount_desc' | 'amount_asc' | 'end_asc' | 'progress_desc' - -const STATUS_OPTIONS: { value: StreamStatus | 'all'; label: string }[] = [ - { value: 'all', label: 'All statuses' }, - { value: 'scheduled', label: 'Scheduled' }, - { value: 'streaming', label: 'Streaming' }, - { value: 'completed', label: 'Completed' }, - { value: 'cancelled', label: 'Cancelled' }, -] - -const SORT_OPTIONS: { value: SortKey; label: string }[] = [ - { value: 'newest', label: 'Newest first' }, - { value: 'oldest', label: 'Oldest first' }, - { value: 'amount_desc', label: 'Highest amount' }, - { value: 'amount_asc', label: 'Lowest amount' }, - { value: 'end_asc', label: 'Ending soonest' }, - { value: 'progress_desc', label: 'Most progress' }, -] - -// ─── Hook: URL-synced filters ───────────────────────────────────────────────── - -function useFilters() { - const router = useRouter() - const params = useSearchParams() - - const search = params.get('q') ?? '' - const status = (params.get('status') ?? 'all') as StreamStatus | 'all' - const token = params.get('token') ?? 'all' - const sort = (params.get('sort') ?? 'newest') as SortKey - - function update(key: string, value: string) { - const next = new URLSearchParams(params.toString()) - if (value === '' || value === 'all' || value === 'newest') { - next.delete(key) - } else { - next.set(key, value) - } - router.replace(`?${next.toString()}`, { scroll: false }) - } - - function clear() { - router.replace('?', { scroll: false }) - } - - const isActive = search !== '' || status !== 'all' || token !== 'all' || sort !== 'newest' - - return { search, status, token, sort, update, clear, isActive } -} - -// ─── Filter + sort logic ────────────────────────────────────────────────────── - -function applyFilters( - streams: StreamData[], - search: string, - status: StreamStatus | 'all', - token: string, - sort: SortKey, - nowSeconds: number, -): StreamData[] { - let result = streams - - if (search) { - const q = search.toLowerCase() - result = result.filter( - (s) => - s.id.toLowerCase().includes(q) || - s.sender.toLowerCase().includes(q) || - s.recipient.toLowerCase().includes(q) || - s.token.symbol.toLowerCase().includes(q), - ) - } - - if (status !== 'all') { - result = result.filter((s) => getStreamStatus(s, nowSeconds) === status) - } - - if (token !== 'all') { - result = result.filter((s) => s.token.symbol === token) - } - - result = [...result].sort((a, b) => { - switch (sort) { - case 'oldest': - return Number(a.startTime - b.startTime) - case 'amount_desc': - return Number(b.depositedAmount - a.depositedAmount) - case 'amount_asc': - return Number(a.depositedAmount - b.depositedAmount) - case 'end_asc': - return Number(a.endTime - b.endTime) - case 'progress_desc': - return getStreamProgress(b, nowSeconds) - getStreamProgress(a, nowSeconds) - case 'newest': - default: - return Number(b.startTime - a.startTime) - } - }) - - return result -} - -// ─── StreamGrid ─────────────────────────────────────────────────────────────── - -function StreamGrid({ streams }: { streams: StreamData[] }) { - if (streams.length === 0) return - return ( -
- {streams.map((s) => ( - - ))} -
- ) -} - -// ─── FilterBar ──────────────────────────────────────────────────────────────── - -function FilterBar({ - tokens, - filters, -}: { - tokens: string[] - filters: ReturnType -}) { - const { search, status, token, sort, update, clear, isActive } = filters - - return ( -
- {/* Search + sort row */} -
-
- - update('q', e.target.value)} - className="pl-9" - /> - {search && ( - - )} -
- -
- - {/* Filter chips row */} -
- - - {tokens.length > 0 && ( - - )} - - {isActive && ( - - - Clear filters - - )} -
-
- ) -} - -// ─── Dashboard ──────────────────────────────────────────────────────────────── -import { SectionErrorBoundary } from '@/components/error-boundary/section-error-boundary' -import { ComponentErrorBoundary } from '@/components/error-boundary/component-error-boundary' -import { useStreams } from '@/hooks/use-streams' -import { useContract } from '@/hooks/use-contract' -import { useNow } from '@/hooks/use-now' -import { useWallet } from '@/hooks/use-wallet' -import { getWithdrawableAmount } from '@/lib/stream-utils' -import { ActivityFeed } from '@/components/streams/activity-feed' - -function Dashboard() { - const { sent, received, all, loading } = useStreams() - const { withdrawAll, pending } = useContract() - const now = useNow(1000) - const filters = useFilters() - const { address: walletAddress } = useWallet() - const [withdrawProgress, setWithdrawProgress] = useState<{ current: number; total: number } | null>(null) - - const withdrawableStreams = received.filter((s) => getWithdrawableAmount(s, now) > 0n) - const isWithdrawingAll = withdrawProgress !== null - - const handleWithdrawAll = async () => { - setWithdrawProgress({ current: 0, total: withdrawableStreams.length }) - try { - await withdrawAll(received, (current, total) => { - setWithdrawProgress({ current, total }) - }) - } finally { - setWithdrawProgress(null) - } - } - - // Derive unique token symbols for the filter dropdown - const tokenSymbols = useMemo( - () => [...new Set(all.map((s) => s.token.symbol))].sort(), - [all], - ) - - const filteredAll = useMemo( - () => applyFilters(all, filters.search, filters.status, filters.token, filters.sort, now), - [all, filters.search, filters.status, filters.token, filters.sort, now], - ) - const filteredReceived = useMemo( - () => applyFilters(received, filters.search, filters.status, filters.token, filters.sort, now), - [received, filters.search, filters.status, filters.token, filters.sort, now], - ) - const filteredSent = useMemo( - () => applyFilters(sent, filters.search, filters.status, filters.token, filters.sort, now), - [sent, filters.search, filters.status, filters.token, filters.sort, now], - ) - - return ( -
- {/* Header */} -
-
-

Dashboard

-

- Your active and historical token streams. -

-
-
- {withdrawableStreams.length > 0 && ( - - )} - -
-
- - {/* Stats */} - {loading ? : } - - - - - {/* Search / filter */} - - - {/* Stream list */} - - - - All ({filteredAll.length}{filteredAll.length !== all.length ? `/${all.length}` : ''}) - - - Receiving ({filteredReceived.length}{filteredReceived.length !== received.length ? `/${received.length}` : ''}) - - - Sending ({filteredSent.length}{filteredSent.length !== sent.length ? `/${sent.length}` : ''}) - - All ({all.length}) - Receiving ({received.length}) - Sending ({sent.length}) - Activity - - - - - All ({all.length}) - Receiving ({received.length}) - Sending ({sent.length}) - - - - {loading ? ( -
- {[0, 1, 2, 4].map((i) => )} -
- ) : all.length === 0 ? ( - - ) : ( -
- {all.map((s) => ( - - ))} -
- )} -
- - - {loading ? ( -
- {[0, 1].map((i) => )} -
- ) : received.length === 0 ? ( - -
- - - {filteredReceived.length === 0 && received.length === 0 ? ( - - ) : ( - - )} - - - {all.length === 0 ? ( - - ) : ( -
- {all.map((s) => ( - - - - ))} -
- )} -
- - - {received.length === 0 ? ( - - ) : ( -
- {received.map((s) => ( - - - - ))} -
- )} -
- - - {loading ? ( -
- {[0, 1].map((i) => )} -
- ) : sent.length === 0 ? ( - {filteredSent.length === 0 && sent.length === 0 ? ( - - ) : ( - - )} -
- - - - -
- - {sent.length === 0 ? ( - - ) : ( -
- {sent.map((s) => ( - - - - ))} -
- )} -
-
- -
- ) -} + title: "Your Streams", + description: + "View and manage your active and historical token streams on FlowStar.", +}; export default function DashboardPage() { return ( - - - + - ) + ); } diff --git a/app/app/stream/[id]/layout.tsx b/app/app/stream/[id]/layout.tsx new file mode 100644 index 0000000..cff978e --- /dev/null +++ b/app/app/stream/[id]/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +export { generateMetadata } from "./metadata"; + +export default function StreamDetailLayout({ + children, +}: { + children: ReactNode; +}) { + return children; +} diff --git a/app/app/stream/[id]/metadata.ts b/app/app/stream/[id]/metadata.ts index 26c1b10..9f2cb38 100644 --- a/app/app/stream/[id]/metadata.ts +++ b/app/app/stream/[id]/metadata.ts @@ -1,40 +1,54 @@ -import { Metadata } from 'next' -import { getStream } from '@/lib/contract' +import { Metadata } from "next"; +import { fetchStream } from "@/lib/contract"; +import { NETWORK } from "@/lib/stellar"; export async function generateMetadata({ params, }: { - params: Promise<{ id: string }> + params: Promise<{ id: string }>; }): Promise { try { - const { id } = await params - const stream = await getStream(id) + const { id } = await params; + const stream = await fetchStream(NETWORK, id); if (!stream) { return { - title: 'Stream not found | FlowStar', - description: 'This stream may not exist or may have expired.', - } + title: "Stream not found | FlowStar", + description: "This stream may not exist or may have expired.", + }; } - const ogImageUrl = new URL('/api/og', 'https://flowstar.app') - ogImageUrl.searchParams.append('amount', (stream.depositedAmount / 10n ** BigInt(stream.token.decimals)).toString()) - ogImageUrl.searchParams.append('symbol', stream.token.symbol) - ogImageUrl.searchParams.append('recipient', stream.recipient) - ogImageUrl.searchParams.append('sender', stream.sender) - ogImageUrl.searchParams.append('status', stream.cancelled ? 'cancelled' : 'active') + const ogImageUrl = new URL("/api/og", "https://flowstar.app"); + ogImageUrl.searchParams.append( + "amount", + ( + stream.depositedAmount / + 10n ** BigInt(stream.token.decimals) + ).toString(), + ); + ogImageUrl.searchParams.append("symbol", stream.token.symbol); + ogImageUrl.searchParams.append("recipient", stream.recipient); + ogImageUrl.searchParams.append("sender", stream.sender); + ogImageUrl.searchParams.append( + "status", + stream.cancelled ? "cancelled" : "active", + ); - const shortenAddress = (addr: string) => addr.slice(0, 6) + '...' + addr.slice(-4) - const amount = (stream.depositedAmount / 10n ** BigInt(stream.token.decimals)).toString() - const description = `${amount} ${stream.token.symbol} streaming to ${shortenAddress(stream.recipient)}` + const shortenAddress = (addr: string) => + addr.slice(0, 6) + "..." + addr.slice(-4); + const amount = ( + stream.depositedAmount / + 10n ** BigInt(stream.token.decimals) + ).toString(); + const description = `${amount} ${stream.token.symbol} streaming to ${shortenAddress(stream.recipient)}`; return { title: `Stream #${stream.id} | FlowStar`, description, - metadataBase: new URL('https://flowstar.app'), + metadataBase: new URL("https://flowstar.app"), openGraph: { - type: 'website', - siteName: 'FlowStar', + type: "website", + siteName: "FlowStar", title: `Stream #${stream.id} | FlowStar`, description, images: [ @@ -47,17 +61,17 @@ export async function generateMetadata({ ], }, twitter: { - card: 'summary_large_image', + card: "summary_large_image", title: `Stream #${stream.id} | FlowStar`, description, images: [ogImageUrl.toString()], }, - } + }; } catch (error) { - console.error('Failed to generate metadata:', error) + console.error("Failed to generate metadata:", error); return { - title: 'Stream | FlowStar', - description: 'Stream tokens by the second on Stellar', - } + title: "Stream | FlowStar", + description: "Stream tokens by the second on Stellar", + }; } } diff --git a/app/app/stream/[id]/page.tsx b/app/app/stream/[id]/page.tsx index 608c678..306110c 100644 --- a/app/app/stream/[id]/page.tsx +++ b/app/app/stream/[id]/page.tsx @@ -1,8 +1,8 @@ -'use client' +"use client"; -import { use, useState } from 'react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' +import { use, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; import { ArrowLeft, ArrowDownLeft, @@ -17,31 +17,35 @@ import { Share2, MessageCircle, Send, -} from 'lucide-react' -import { toast } from 'sonner' -import { ConnectWalletButton } from '@/components/layout/connect-wallet-button' -import { StreamStatusBadge } from '@/components/streams/stream-status-badge' -import { ProgressBar } from '@/components/ui/progress-bar' -import { TokenAmount } from '@/components/ui/token-amount' -import { CountdownTimer } from '@/components/ui/countdown-timer' -import { AccessibleCountdownTimer } from '@/components/ui/accessible-countdown-timer' -import { AccessibleUnlockAmount } from '@/components/ui/accessible-unlock-amount' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { FeeEstimateDialog } from '@/components/ui/fee-estimate-dialog' +} from "lucide-react"; +import { toast } from "sonner"; +import { ConnectWalletButton } from "@/components/layout/connect-wallet-button"; +import { StreamStatusBadge } from "@/components/streams/stream-status-badge"; +import { ProgressBar } from "@/components/ui/progress-bar"; +import { TokenAmount } from "@/components/ui/token-amount"; +import { CountdownTimer } from "@/components/ui/countdown-timer"; +import { AccessibleCountdownTimer } from "@/components/ui/accessible-countdown-timer"; +import { AccessibleUnlockAmount } from "@/components/ui/accessible-unlock-amount"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { FeeEstimateDialog } from "@/components/ui/fee-estimate-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, -} from '@/components/ui/dialog' -import { calculateFeeBreakdown, TYPICAL_FEES, isHighFee } from '@/lib/fee-utils' -import { useStream } from '@/hooks/use-streams' -import { useContract } from '@/hooks/use-contract' -import { useWallet } from '@/hooks/use-wallet' -import { useNow } from '@/hooks/use-now' +} from "@/components/ui/dialog"; +import { + calculateFeeBreakdown, + TYPICAL_FEES, + isHighFee, +} from "@/lib/fee-utils"; +import { useStream } from "@/hooks/use-streams"; +import { useContract } from "@/hooks/use-contract"; +import { useWallet } from "@/hooks/use-wallet"; +import { useNow } from "@/hooks/use-now"; import { getStreamStatus, getStreamProgress, @@ -52,23 +56,29 @@ import { parseTokenAmount, shortenAddress, formatRate, -} from '@/lib/stream-utils' -import { explorerUrl } from '@/lib/stellar' -import { useNetwork } from '@/components/providers/network-provider' -import { useAutoWithdraw } from '@/hooks/use-auto-withdraw' -import { UnlockChart } from '@/components/streams/unlock-chart' -import { StreamTimeline } from '@/components/streams/stream-timeline' -import { DownloadReceiptButton } from '@/components/streams/download-receipt-button' -import { bumpStreamTtl } from '@/lib/contract' +} from "@/lib/stream-utils"; +import { explorerUrl } from "@/lib/stellar"; +import { useNetwork } from "@/components/providers/network-provider"; +import { useAutoWithdraw } from "@/hooks/use-auto-withdraw"; +import { UnlockChart } from "@/components/streams/unlock-chart"; +import { StreamTimeline } from "@/components/streams/stream-timeline"; +import { DownloadReceiptButton } from "@/components/streams/download-receipt-button"; +import { bumpStreamTtl } from "@/lib/contract"; // ─── Address copy button ──────────────────────────────────────────────────── -function CopyableAddress({ address, href }: { address: string; href?: string }) { - const [copied, setCopied] = useState(false) +function CopyableAddress({ + address, + href, +}: { + address: string; + href?: string; +}) { + const [copied, setCopied] = useState(false); function copy() { - navigator.clipboard.writeText(address) - setCopied(true) - setTimeout(() => setCopied(false), 1500) + navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 1500); } return ( @@ -76,7 +86,9 @@ function CopyableAddress({ address, href }: { address: string; href?: string }) onClick={copy} className="group inline-flex items-center gap-1.5 font-mono text-sm hover:text-primary transition-colors" > - {shortenAddress(address, 6)} + + {shortenAddress(address, 6)} + {copied ? ( ) : ( @@ -95,18 +107,24 @@ function CopyableAddress({ address, href }: { address: string; href?: string }) )} - ) + ); } // ─── Detail row ───────────────────────────────────────────────────────────── -function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { +function DetailRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { return (
{label} {children}
- ) + ); } // ─── Withdraw dialog ──────────────────────────────────────────────────────── @@ -118,40 +136,46 @@ function WithdrawDialog({ withdrawable, token, }: { - open: boolean - onClose: () => void - streamId: string - withdrawable: bigint - token: { symbol: string; decimals: number; address: string } + open: boolean; + onClose: () => void; + streamId: string; + withdrawable: bigint; + token: { symbol: string; decimals: number; address: string }; }) { - const { withdraw, pending, error } = useContract() - const { network } = useNetwork() - const [inputAmount, setInputAmount] = useState('') - const [showFeeEstimate, setShowFeeEstimate] = useState(false) + const { withdraw, pending, error } = useContract(); + const { network } = useNetwork(); + const [inputAmount, setInputAmount] = useState(""); + const [showFeeEstimate, setShowFeeEstimate] = useState(false); - const max = formatTokenAmount(withdrawable, token.decimals, token.decimals) - const parsed = inputAmount ? parseTokenAmount(inputAmount, token.decimals) : 0n - const invalid = parsed <= 0n || parsed > withdrawable + const max = formatTokenAmount(withdrawable, token.decimals, token.decimals); + const parsed = inputAmount + ? parseTokenAmount(inputAmount, token.decimals) + : 0n; + const invalid = parsed <= 0n || parsed > withdrawable; // Calculate estimated fees - const estimatedFee = TYPICAL_FEES.withdraw.typical - const feeBreakdown = calculateFeeBreakdown(estimatedFee) - const withdrawFeeHigh = isHighFee(estimatedFee, TYPICAL_FEES.withdraw.typical) + const estimatedFee = TYPICAL_FEES.withdraw.typical; + const feeBreakdown = calculateFeeBreakdown(estimatedFee); + const withdrawFeeHigh = isHighFee( + estimatedFee, + TYPICAL_FEES.withdraw.typical, + ); async function handleWithdraw() { try { - const hash = await withdraw(streamId, parsed) - toast.success('Withdrawal successful', { + const hash = await withdraw(streamId, parsed); + toast.success("Withdrawal successful", { description: `${formatTokenAmount(parsed, token.decimals, 4)} ${token.symbol} sent to your wallet.`, ...(hash && { action: { - label: 'View transaction', - onClick: () => window.open(explorerUrl(network, 'tx', hash), '_blank'), + label: "View transaction", + onClick: () => + window.open(explorerUrl(network, "tx", hash), "_blank"), }, }), - }) - onClose() - setInputAmount('') + }); + onClose(); + setInputAmount(""); } catch { // error shown inline } @@ -159,12 +183,15 @@ function WithdrawDialog({ return ( <> - !o && onClose()}> + !o && onClose()} + > Withdraw funds - Enter how much to withdraw. Max:{' '} + Enter how much to withdraw. Max:{" "} {max} {token.symbol} @@ -194,14 +221,19 @@ function WithdrawDialog({
{inputAmount && invalid && ( -

Amount exceeds withdrawable balance

+

+ Amount exceeds withdrawable balance +

)}
{/* Fee info */}

- Estimated network fee: {(estimatedFee / 1e7).toFixed(7)} XLM + Estimated network fee:{" "} + + {(estimatedFee / 1e7).toFixed(7)} XLM +

Fee will be shown again before wallet confirmation. @@ -210,12 +242,14 @@ function WithdrawDialog({ {error &&

{error}

}
- +
@@ -233,7 +267,7 @@ function WithdrawDialog({ loading={pending} /> - ) + ); } // ─── Cancel dialog ─────────────────────────────────────────────────────────── @@ -243,33 +277,35 @@ function CancelDialog({ onClose, streamId, }: { - open: boolean - onClose: () => void - streamId: string + open: boolean; + onClose: () => void; + streamId: string; }) { - const { cancel, pending, error } = useContract() - const { network } = useNetwork() - const router = useRouter() - const [showFeeEstimate, setShowFeeEstimate] = useState(false) + const { cancel, pending, error } = useContract(); + const { network } = useNetwork(); + const router = useRouter(); + const [showFeeEstimate, setShowFeeEstimate] = useState(false); - const estimatedFee = TYPICAL_FEES.cancel.typical - const feeBreakdown = calculateFeeBreakdown(estimatedFee) - const cancelFeeHigh = isHighFee(estimatedFee, TYPICAL_FEES.cancel.typical) + const estimatedFee = TYPICAL_FEES.cancel.typical; + const feeBreakdown = calculateFeeBreakdown(estimatedFee); + const cancelFeeHigh = isHighFee(estimatedFee, TYPICAL_FEES.cancel.typical); async function handleCancel() { try { - const hash = await cancel(streamId) - toast.success('Stream cancelled', { - description: 'Unlocked funds sent to recipient. Remainder returned to you.', + const hash = await cancel(streamId); + toast.success("Stream cancelled", { + description: + "Unlocked funds sent to recipient. Remainder returned to you.", ...(hash && { action: { - label: 'View transaction', - onClick: () => window.open(explorerUrl(network, 'tx', hash), '_blank'), + label: "View transaction", + onClick: () => + window.open(explorerUrl(network, "tx", hash), "_blank"), }, }), - }) - onClose() - router.push('/app') + }); + onClose(); + router.push("/app"); } catch { // error shown inline } @@ -277,7 +313,10 @@ function CancelDialog({ return ( <> - !o && onClose()}> + !o && onClose()} + > Cancel stream @@ -290,19 +329,24 @@ function CancelDialog({ {/* Fee info */}

- Estimated network fee: {(estimatedFee / 1e7).toFixed(7)} XLM + Estimated network fee:{" "} + + {(estimatedFee / 1e7).toFixed(7)} XLM +

{error &&

{error}

}
- +
@@ -319,34 +363,60 @@ function CancelDialog({ loading={pending} /> - ) + ); } // ─── Auto-withdraw settings ───────────────────────────────────────────────── const INTERVAL_OPTIONS = [ - { label: 'Every 6 hours', hours: 6 }, - { label: 'Every 12 hours', hours: 12 }, - { label: 'Every 24 hours', hours: 24 }, - { label: 'Every 48 hours', hours: 48 }, -] as const + { label: "Every 6 hours", hours: 6 }, + { label: "Every 12 hours", hours: 12 }, + { label: "Every 24 hours", hours: 24 }, + { label: "Every 48 hours", hours: 48 }, +] as const; function AutoWithdrawSection({ stream, }: { - stream: import('@/types/stream').StreamData + stream: import("@/types/stream").StreamData; }) { - const { settings, updateSettings, lastAutoWithdraw, autoWithdrawPending, withdrawalHistory } = useAutoWithdraw(stream) - const [minDisplay, setMinDisplay] = useState('') - const [maxDisplay, setMaxDisplay] = useState('') - const [showHistory, setShowHistory] = useState(false) - - const STRATEGY_OPTIONS: Array<{ value: import('@/hooks/use-auto-withdraw').WithdrawStrategy; label: string; description: string }> = [ - { value: 'time-based', label: 'Time-based', description: 'Withdraw on fixed intervals' }, - { value: 'threshold-based', label: 'Threshold-based', description: 'Withdraw when amount reaches threshold' }, - { value: 'gas-optimized', label: 'Gas-optimized', description: 'Limit frequency to reduce gas costs' }, - { value: 'max', label: 'Max amount', description: 'Always withdraw maximum available' }, - ] + const { + settings, + updateSettings, + lastAutoWithdraw, + autoWithdrawPending, + withdrawalHistory, + } = useAutoWithdraw(stream); + const [minDisplay, setMinDisplay] = useState(""); + const [maxDisplay, setMaxDisplay] = useState(""); + const [showHistory, setShowHistory] = useState(false); + + const STRATEGY_OPTIONS: Array<{ + value: import("@/hooks/use-auto-withdraw").WithdrawStrategy; + label: string; + description: string; + }> = [ + { + value: "time-based", + label: "Time-based", + description: "Withdraw on fixed intervals", + }, + { + value: "threshold-based", + label: "Threshold-based", + description: "Withdraw when amount reaches threshold", + }, + { + value: "gas-optimized", + label: "Gas-optimized", + description: "Limit frequency to reduce gas costs", + }, + { + value: "max", + label: "Max amount", + description: "Always withdraw maximum available", + }, + ]; return (
@@ -372,7 +442,8 @@ function AutoWithdrawSection({ {settings.enabled && (

- Automatically withdraw funds using your chosen strategy. The app must be open and your wallet connected. + Automatically withdraw funds using your chosen strategy. The app + must be open and your wallet connected.

@@ -384,20 +455,22 @@ function AutoWithdrawSection({ type="button" onClick={() => updateSettings({ strategy: opt.value })} className={ - 'w-full text-left p-3 rounded-lg border transition-colors ' + + "w-full text-left p-3 rounded-lg border transition-colors " + (settings.strategy === opt.value - ? 'border-primary bg-primary/10' - : 'border-border hover:border-primary/50') + ? "border-primary bg-primary/10" + : "border-border hover:border-primary/50") } >

{opt.label}

-

{opt.description}

+

+ {opt.description} +

))}
- {settings.strategy === 'time-based' && ( + {settings.strategy === "time-based" && (
@@ -407,10 +480,10 @@ function AutoWithdrawSection({ type="button" onClick={() => updateSettings({ intervalHours: opt.hours })} className={ - 'rounded-full border px-3 py-1 text-xs font-medium transition-colors ' + + "rounded-full border px-3 py-1 text-xs font-medium transition-colors " + (settings.intervalHours === opt.hours - ? 'border-primary bg-primary/10 text-primary' - : 'border-border text-muted-foreground hover:border-primary hover:text-primary') + ? "border-primary bg-primary/10 text-primary" + : "border-border text-muted-foreground hover:border-primary hover:text-primary") } > {opt.label} @@ -420,7 +493,7 @@ function AutoWithdrawSection({
)} - {settings.strategy === 'threshold-based' && ( + {settings.strategy === "threshold-based" && (
@@ -449,16 +526,20 @@ function AutoWithdrawSection({ placeholder="0 (no minimum)" value={minDisplay} onChange={(e) => { - setMinDisplay(e.target.value) + setMinDisplay(e.target.value); const raw = e.target.value - ? parseTokenAmount(e.target.value, stream.token.decimals).toString() - : '0' - updateSettings({ minAmountRaw: raw }) + ? parseTokenAmount( + e.target.value, + stream.token.decimals, + ).toString() + : "0"; + updateSettings({ minAmountRaw: raw }); }} className="max-w-48" />

- Skip auto-withdraw if the available amount is below this threshold. + Skip auto-withdraw if the available amount is below this + threshold.

@@ -474,11 +555,14 @@ function AutoWithdrawSection({ placeholder="0 (no limit)" value={maxDisplay} onChange={(e) => { - setMaxDisplay(e.target.value) + setMaxDisplay(e.target.value); const raw = e.target.value - ? parseTokenAmount(e.target.value, stream.token.decimals).toString() - : '0' - updateSettings({ maxSafetyLimitRaw: raw }) + ? parseTokenAmount( + e.target.value, + stream.token.decimals, + ).toString() + : "0"; + updateSettings({ maxSafetyLimitRaw: raw }); }} className="max-w-48" /> @@ -492,7 +576,8 @@ function AutoWithdrawSection({ )} {lastAutoWithdraw && (

- Last auto-withdrawal: {new Date(lastAutoWithdraw).toLocaleTimeString()} + Last auto-withdrawal:{" "} + {new Date(lastAutoWithdraw).toLocaleTimeString()}

)} @@ -502,15 +587,25 @@ function AutoWithdrawSection({ onClick={() => setShowHistory(!showHistory)} className="text-xs text-primary hover:underline" > - {showHistory ? 'Hide' : 'Show'} withdrawal history ({withdrawalHistory.length}) + {showHistory ? "Hide" : "Show"} withdrawal history ( + {withdrawalHistory.length}) {showHistory && (
{withdrawalHistory.map((entry, idx) => ( -
+

{new Date(entry.timestamp).toLocaleTimeString()}

-

- {entry.error ? `Error: ${entry.error}` : `Withdrew: ${entry.amount}`} +

+ {entry.error + ? `Error: ${entry.error}` + : `Withdrew: ${entry.amount}`}

))} @@ -521,46 +616,57 @@ function AutoWithdrawSection({
)}
- ) + ); } // ─── TTL warning ─────────────────────────────────────────────────────────── -const TTL_DAYS = 30 -const WARN_DAYS = 7 +const TTL_DAYS = 30; +const WARN_DAYS = 7; -function estimateDaysSinceLastWrite(stream: import('@/types/stream').StreamData, nowSeconds: number): number { - const now = BigInt(nowSeconds) - const lastWrite = stream.withdrawnAmount > 0n - ? now - : stream.cancelled +function estimateDaysSinceLastWrite( + stream: import("@/types/stream").StreamData, + nowSeconds: number, +): number { + const now = BigInt(nowSeconds); + const lastWrite = + stream.withdrawnAmount > 0n ? now - : stream.startTime - return Number(now - lastWrite) / 86400 + : stream.cancelled + ? now + : stream.startTime; + return Number(now - lastWrite) / 86400; } -function TtlWarning({ stream, nowSeconds }: { stream: import('@/types/stream').StreamData; nowSeconds: number }) { - const { address } = useWallet() - const { network } = useNetwork() - const [bumping, setBumping] = useState(false) - const [bumped, setBumped] = useState(false) +function TtlWarning({ + stream, + nowSeconds, +}: { + stream: import("@/types/stream").StreamData; + nowSeconds: number; +}) { + const { address } = useWallet(); + const { network } = useNetwork(); + const [bumping, setBumping] = useState(false); + const [bumped, setBumped] = useState(false); - const daysSinceWrite = estimateDaysSinceLastWrite(stream, nowSeconds) - const estimatedDaysLeft = TTL_DAYS - daysSinceWrite + const daysSinceWrite = estimateDaysSinceLastWrite(stream, nowSeconds); + const estimatedDaysLeft = TTL_DAYS - daysSinceWrite; - if (bumped || estimatedDaysLeft > WARN_DAYS || estimatedDaysLeft < 0) return null + if (bumped || estimatedDaysLeft > WARN_DAYS || estimatedDaysLeft < 0) + return null; async function handleBump() { - if (!address) return - setBumping(true) + if (!address) return; + setBumping(true); try { - await bumpStreamTtl(network, stream.id, address) - setBumped(true) - toast.success('Storage TTL extended by 30 days') + await bumpStreamTtl(network, stream.id, address); + setBumped(true); + toast.success("Storage TTL extended by 30 days"); } catch { - toast.error('Failed to extend TTL') + toast.error("Failed to extend TTL"); } finally { - setBumping(false) + setBumping(false); } } @@ -572,8 +678,10 @@ function TtlWarning({ stream, nowSeconds }: { stream: import('@/types/stream').S Storage may be expiring soon

- This stream's on-chain data may expire in ~{Math.max(0, Math.floor(estimatedDaysLeft))} day{Math.floor(estimatedDaysLeft) !== 1 ? 's' : ''}. - Extend the TTL to prevent data loss and keep the stream active. + This stream's on-chain data may expire in ~ + {Math.max(0, Math.floor(estimatedDaysLeft))} day + {Math.floor(estimatedDaysLeft) !== 1 ? "s" : ""}. Extend the TTL to + prevent data loss and keep the stream active.

{address && ( )}
- ) + ); } // ─── Rate display ────────────────────────────────────────────────────────── -function RateDisplay({ stream }: { stream: import('@/types/stream').StreamData }) { - const [expanded, setExpanded] = useState(false) - const rate = formatRate(stream.amountPerSecond, stream.token.decimals, stream.token.symbol) +function RateDisplay({ + stream, +}: { + stream: import("@/types/stream").StreamData; +}) { + const [expanded, setExpanded] = useState(false); + const rate = formatRate( + stream.amountPerSecond, + stream.token.decimals, + stream.token.symbol, + ); return (
@@ -615,7 +731,7 @@ function RateDisplay({ stream }: { stream: import('@/types/stream').StreamData }
)} - ) + ); } // ─── Stream detail skeleton ────────────────────────────────────────────────── @@ -656,40 +772,46 @@ function StreamDetailSkeleton() {
{[0, 1, 2, 3, 4].map((i) => ( -
+
))}
- ) + ); } // ─── Main page ─────────────────────────────────────────────────────────────── function ShareButtons({ streamId }: { streamId: string }) { - const [copied, setCopied] = useState(false) - const [showShare, setShowShare] = useState(false) + const [copied, setCopied] = useState(false); + const [showShare, setShowShare] = useState(false); - const streamUrl = typeof window !== 'undefined' ? window.location.href : `https://flowstar.app/app/stream/${streamId}` - const shareText = `Check out this token stream on FlowStar - Stream #${streamId}` + const streamUrl = + typeof window !== "undefined" + ? window.location.href + : `https://flowstar.app/app/stream/${streamId}`; + const shareText = `Check out this token stream on FlowStar - Stream #${streamId}`; function copyLink() { - navigator.clipboard.writeText(streamUrl) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - toast.success('Link copied to clipboard') + navigator.clipboard.writeText(streamUrl); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + toast.success("Link copied to clipboard"); } function shareToTwitter() { - const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(streamUrl)}` - window.open(url, '_blank', 'width=550,height=420') + const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(streamUrl)}`; + window.open(url, "_blank", "width=550,height=420"); } function shareToTelegram() { - const url = `https://t.me/share/url?url=${encodeURIComponent(streamUrl)}&text=${encodeURIComponent(shareText)}` - window.open(url, '_blank', 'width=550,height=420') + const url = `https://t.me/share/url?url=${encodeURIComponent(streamUrl)}&text=${encodeURIComponent(shareText)}`; + window.open(url, "_blank", "width=550,height=420"); } return ( @@ -711,8 +833,12 @@ function ShareButtons({ streamId }: { streamId: string }) { className="inline-flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-secondary rounded transition-colors" aria-label="Copy link" > - {copied ? : } - {copied ? 'Copied!' : 'Copy link'} + {copied ? ( + + ) : ( + + )} + {copied ? "Copied!" : "Copy link"}
- ) + ); } function ConnectPrompt() { @@ -748,20 +874,20 @@ function ConnectPrompt() {

- ) + ); } function StreamDetail({ id }: { id: string }) { - const { stream, loading } = useStream(id) - const { address, isConnected } = useWallet() - const { network, config } = useNetwork() - const router = useRouter() - const now = useNow(1000) - const [withdrawOpen, setWithdrawOpen] = useState(false) - const [cancelOpen, setCancelOpen] = useState(false) + const { stream, loading } = useStream(id); + const { address, isConnected } = useWallet(); + const { network, config } = useNetwork(); + const router = useRouter(); + const now = useNow(1000); + const [withdrawOpen, setWithdrawOpen] = useState(false); + const [cancelOpen, setCancelOpen] = useState(false); if (loading) { - return + return ; } if (!stream) { @@ -775,41 +901,50 @@ function StreamDetail({ id }: { id: string }) { Back to dashboard
- ) + ); } - const status = getStreamStatus(stream, now) - const progress = getStreamProgress(stream, now) - const unlocked = getUnlockedAmount(stream, now) - const withdrawable = getWithdrawableAmount(stream, now) + const status = getStreamStatus(stream, now); + const progress = getStreamProgress(stream, now); + const unlocked = getUnlockedAmount(stream, now); + const withdrawable = getWithdrawableAmount(stream, now); const withdrawnFrac = stream.depositedAmount > 0n - ? Number((stream.withdrawnAmount * 10000n) / stream.depositedAmount) / 10000 - : 0 + ? Number((stream.withdrawnAmount * 10000n) / stream.depositedAmount) / + 10000 + : 0; - const isRecipient = address === stream.recipient - const isSender = address === stream.sender - const canWithdraw = isRecipient && !stream.cancelled && withdrawable > 0n - const canCancel = isSender && !stream.cancelled && status !== 'completed' + const isRecipient = address === stream.recipient; + const isSender = address === stream.sender; + const canWithdraw = isRecipient && !stream.cancelled && withdrawable > 0n; + const canCancel = isSender && !stream.cancelled && status !== "completed"; function handleDuplicate() { - const durationSecs = Number(stream.endTime - stream.startTime) - const cliffSecs = Number(stream.cliffTime - stream.startTime) - const hasCliff = stream.cliffTime > stream.startTime + if (!stream) return; + const durationSecs = Number(stream.endTime - stream.startTime); + const cliffSecs = Number(stream.cliffTime - stream.startTime); + const hasCliff = stream.cliffTime > stream.startTime; const params = new URLSearchParams({ clone: stream.id, recipient: stream.recipient, token: stream.token.address, - amount: (Number(stream.depositedAmount) / Math.pow(10, stream.token.decimals)).toString(), + amount: ( + Number(stream.depositedAmount) / Math.pow(10, stream.token.decimals) + ).toString(), duration: durationSecs.toString(), - }) + }); if (hasCliff) { - params.set('cliff', cliffSecs.toString()) + params.set("cliff", cliffSecs.toString()); if (stream.cliffAmount > 0n) { - params.set('cliffAmount', (Number(stream.cliffAmount) / Math.pow(10, stream.token.decimals)).toString()) + params.set( + "cliffAmount", + ( + Number(stream.cliffAmount) / Math.pow(10, stream.token.decimals) + ).toString(), + ); } } - router.push(`/app/create?${params.toString()}`) + router.push(`/app/create?${params.toString()}`); } return ( @@ -835,8 +970,10 @@ function StreamDetail({ id }: { id: string }) {
{isSender ? ( @@ -847,14 +984,16 @@ function StreamDetail({ id }: { id: string }) {

- {isSender ? 'Sending' : 'Receiving'}{' '} + {isSender ? "Sending" : "Receiving"}{" "}

-

Stream #{stream.id}

+

+ Stream #{stream.id} +

@@ -871,8 +1010,10 @@ function StreamDetail({ id }: { id: string }) { decimals={stream.token.decimals} symbol={stream.token.symbol} className="font-mono text-3xl font-semibold tabular-nums" - isCompleted={status === 'completed'} - isCliffReached={Number(stream.cliffTime) <= now && stream.cliffAmount > 0n} + isCompleted={status === "completed"} + isCliffReached={ + Number(stream.cliffTime) <= now && stream.cliffAmount > 0n + } /> @@ -882,7 +1023,7 @@ function StreamDetail({ id }: { id: string }) {
{(progress * 100).toFixed(2)}% unlocked @@ -892,31 +1033,39 @@ function StreamDetail({ id }: { id: string }) { token={stream.token} showSymbol={false} maxFractionDigits={2} - />{' '} - / {" "} + /{" "} + {' '} + />{" "} withdrawn
{/* Countdown */} - {(status === 'streaming' || status === 'scheduled') && ( + {(status === "streaming" || status === "scheduled") && (
- {status === 'scheduled' && ( + {status === "scheduled" && (

Starts in

- +
)}

- {status === 'scheduled' ? 'Duration' : 'Ends in'} + {status === "scheduled" ? "Duration" : "Ends in"}

- +
)} @@ -927,18 +1076,15 @@ function StreamDetail({ id }: { id: string }) { {canWithdraw && ( )} {canCancel && ( - )} @@ -958,7 +1104,7 @@ function StreamDetail({ id }: { id: string }) { {/* TTL warning */} - {!stream.cancelled && status !== 'completed' && ( + {!stream.cancelled && status !== "completed" && ( )} @@ -973,17 +1119,23 @@ function StreamDetail({ id }: { id: string }) { Details - + - +
{stream.token.symbol}
@@ -991,19 +1143,31 @@ function StreamDetail({ id }: { id: string }) { )} - + - + - 0n ? 'text-primary font-medium' : ''}> - + 0n ? "text-primary font-medium" : ""}> + @@ -1013,7 +1177,11 @@ function StreamDetail({ id }: { id: string }) { {formatDateTime(stream.startTime)} - ({new Date(Number(stream.startTime) * 1000).toUTCString().replace(' GMT', ' UTC')}) + ( + {new Date(Number(stream.startTime) * 1000) + .toUTCString() + .replace(" GMT", " UTC")} + ) @@ -1022,7 +1190,13 @@ function StreamDetail({ id }: { id: string }) { {formatDateTime(stream.cliffTime)} {stream.cliffAmount > 0n && ( - (+) + (+ + + ) )} @@ -1031,7 +1205,11 @@ function StreamDetail({ id }: { id: string }) { {formatDateTime(stream.endTime)} - ({new Date(Number(stream.endTime) * 1000).toUTCString().replace(' GMT', ' UTC')}) + ( + {new Date(Number(stream.endTime) * 1000) + .toUTCString() + .replace(" GMT", " UTC")} + ) @@ -1044,7 +1222,7 @@ function StreamDetail({ id }: { id: string }) { {/* Auto-withdraw (recipients only, active streams) */} - {isRecipient && !stream.cancelled && status !== 'completed' && ( + {isRecipient && !stream.cancelled && status !== "completed" && ( )} @@ -1062,14 +1240,14 @@ function StreamDetail({ id }: { id: string }) { streamId={stream.id} /> - ) + ); } export default function StreamPage({ params, }: { - params: Promise<{ id: string }> + params: Promise<{ id: string }>; }) { - const { id } = use(params) - return + const { id } = use(params); + return ; } diff --git a/components/CommandPalette.tsx b/components/CommandPalette.tsx deleted file mode 100644 index 26b3131..0000000 --- a/components/CommandPalette.tsx +++ /dev/null @@ -1,234 +0,0 @@ -'use client' - -import { Command } from 'cmdk' -import { useEffect, useRef, useState } from 'react' -import { useRouter } from 'next/navigation' - -const ACTIONS = [ - { id: 'dashboard', label: 'Go to Dashboard', shortcut: 'G D', href: '/app' }, - { id: 'create', label: 'Create New Stream', shortcut: 'G C', href: '/app/create' }, - { id: 'streams', label: 'View All Streams', shortcut: '', href: '/app/streams' }, - { id: 'analytics', label: 'Analytics', shortcut: '', href: '/app/analytics' }, - { id: 'help', label: 'Keyboard Shortcut Help', shortcut: 'Ctrl+/', href: '#help' }, -] - -function FocusTrap({ - children, - onEscape, -}: { - children: React.ReactNode - onEscape: () => void -}) { - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - - const focusable = () => - Array.from( - el.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ) - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') { - onEscape() - return - } - if (e.key !== 'Tab') return - const nodes = focusable() - if (nodes.length === 0) return - const first = nodes[0] - const last = nodes[nodes.length - 1] - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } - - el.addEventListener('keydown', handleKeyDown) - return () => el.removeEventListener('keydown', handleKeyDown) - }, [onEscape]) - - return
{children}
-} - -export function CommandPalette() { - const [open, setOpen] = useState(false) - const [showHelp, setShowHelp] = useState(false) - const router = useRouter() - const triggerRef = useRef(null) - - function closeAll() { - setOpen(false) - setShowHelp(false) - ;(triggerRef.current as HTMLElement | null)?.focus() - } - - useEffect(() => { - const down = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return - - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault() - triggerRef.current = document.activeElement as HTMLElement - setOpen((o) => !o) - } - if ((e.metaKey || e.ctrlKey) && e.key === '/') { - e.preventDefault() - triggerRef.current = document.activeElement as HTMLElement - setShowHelp((o) => !o) - } - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault() - router.push('/app/create') - } - } - - let gPressed = false - const chord = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return - if (e.key === 'g') { - gPressed = true - setTimeout(() => { - gPressed = false - }, 1000) - return - } - if (gPressed) { - if (e.key === 'd') router.push('/app') - if (e.key === 'c') router.push('/app/create') - gPressed = false - } - } - - window.addEventListener('keydown', down) - window.addEventListener('keydown', chord) - return () => { - window.removeEventListener('keydown', down) - window.removeEventListener('keydown', chord) - } - }, [router]) - - return ( - <> - {open && ( - - )} - - {showHelp && ( - - )} - - ) -} diff --git a/components/NetworkMismatchModal.tsx b/components/NetworkMismatchModal.tsx deleted file mode 100644 index d19425b..0000000 --- a/components/NetworkMismatchModal.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { getNetworkName, APP_NETWORK } from '@/lib/network' - -interface Props { - walletNetwork: string - onDismiss: () => void -} - -export function NetworkMismatchModal({ walletNetwork, onDismiss }: Props) { - const dismissRef = useRef(null) - const linkRef = useRef(null) - const containerRef = useRef(null) - - // Auto-focus dismiss button on mount, restore on close - useEffect(() => { - const previouslyFocused = document.activeElement as HTMLElement | null - dismissRef.current?.focus() - return () => { - previouslyFocused?.focus() - } - }, []) - - // Escape key closes modal - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') { - onDismiss() - return - } - // Focus trap: cycle Tab within modal - if (e.key === 'Tab' && containerRef.current) { - const focusable = Array.from( - containerRef.current.querySelectorAll( - 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ) - if (focusable.length === 0) return - const first = focusable[0] - const last = focusable[focusable.length - 1] - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault() - last.focus() - } - } else { - if (document.activeElement === last) { - e.preventDefault() - first.focus() - } - } - } - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [onDismiss]) - - return ( -
-
-

- ⚠️ Wrong Network -

-

- Your wallet is connected to {walletNetwork}. FlowStar - requires {getNetworkName(APP_NETWORK)}. -

-

- Please open Freighter, go to Settings → Network, and switch to{' '} - {getNetworkName(APP_NETWORK)}. -

- -
-
- ) -} diff --git a/components/layout/testnet-faucet-banner.tsx b/components/layout/testnet-faucet-banner.tsx index b018cb4..5cf5971 100644 --- a/components/layout/testnet-faucet-banner.tsx +++ b/components/layout/testnet-faucet-banner.tsx @@ -1,15 +1,15 @@ -'use client' +"use client"; -import { useState, useEffect } from 'react' -import { AlertCircle, Loader2, Check, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { useWallet } from '@/hooks/use-wallet' -import { useNetwork } from '@/components/providers/network-provider' -import { getXlmBalance } from '@/lib/stellar' -import { toast } from 'sonner' +import { useState, useEffect } from "react"; +import { AlertCircle, Loader2, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useWallet } from "@/hooks/use-wallet"; +import { useNetwork } from "@/components/providers/network-provider"; +import { getXlmBalance } from "@/lib/stellar"; +import { toast } from "sonner"; interface TestnetFaucetBannerProps { - onClose?: () => void + onClose?: () => void; } /** @@ -18,92 +18,98 @@ interface TestnetFaucetBannerProps { * account via Friendbot. */ export function TestnetFaucetBanner({ onClose }: TestnetFaucetBannerProps) { - const { address, isConnected } = useWallet() - const { network } = useNetwork() - - const [xlmBalance, setXlmBalance] = useState(null) - const [loading, setLoading] = useState(true) - const [funding, setFunding] = useState(false) - const [fundingStatus, setFundingStatus] = useState<'idle' | 'success' | 'error'>('idle') - const [errorMessage, setErrorMessage] = useState('') - const [dismissed, setDismissed] = useState(false) + const { address, isConnected } = useWallet(); + const { network } = useNetwork(); + + const [xlmBalance, setXlmBalance] = useState(null); + const [loading, setLoading] = useState(true); + const [funding, setFunding] = useState(false); + const [fundingStatus, setFundingStatus] = useState< + "idle" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [dismissed, setDismissed] = useState(false); // Only show on testnet - const isTestnet = network === 'testnet' + const isTestnet = network === "testnet"; // Fetch balance when address changes useEffect(() => { if (!address || !isConnected || !isTestnet) { - setLoading(false) - return + setLoading(false); + return; } - setLoading(true) - getXlmBalance(address, 'testnet') + setLoading(true); + getXlmBalance(address, "testnet") .then((balance) => { - setXlmBalance(balance) - setLoading(false) + setXlmBalance(balance); + setLoading(false); }) .catch(() => { - setLoading(false) - }) - }, [address, isConnected, isTestnet]) + setLoading(false); + }); + }, [address, isConnected, isTestnet]); // Hide if not applicable if (dismissed || !isTestnet || !isConnected || !address || loading) { - return null + return null; } // Hide if balance is not zero - const isZeroBalance = xlmBalance === null || xlmBalance === 0n + const isZeroBalance = xlmBalance === null || xlmBalance === 0n; if (!isZeroBalance) { - return null + return null; } async function fundWithFriendbot() { - setFunding(true) - setFundingStatus('idle') - setErrorMessage('') + if (!address) return; + setFunding(true); + setFundingStatus("idle"); + setErrorMessage(""); try { // Call Friendbot - const friendbotUrl = `https://friendbot.stellar.org?addr=${encodeURIComponent(address)}` - const response = await fetch(friendbotUrl) + const friendbotUrl = `https://friendbot.stellar.org?addr=${encodeURIComponent(address)}`; + const response = await fetch(friendbotUrl); if (!response.ok) { - const errorData = await response.text() - throw new Error(errorData || 'Friendbot funding failed') + const errorData = await response.text(); + throw new Error(errorData || "Friendbot funding failed"); } // Wait a moment for the transaction to be confirmed - await new Promise((resolve) => setTimeout(resolve, 3000)) + await new Promise((resolve) => setTimeout(resolve, 3000)); // Refresh balance - const newBalance = await getXlmBalance(address, 'testnet') - setXlmBalance(newBalance) - setFundingStatus('success') + const newBalance = await getXlmBalance(address, "testnet"); + setXlmBalance(newBalance); + setFundingStatus("success"); - toast.success('Account funded!', { - description: 'Your testnet account has been funded with test XLM from Friendbot.', - }) + toast.success("Account funded!", { + description: + "Your testnet account has been funded with test XLM from Friendbot.", + }); // Auto-dismiss after success setTimeout(() => { - setDismissed(true) - onClose?.() - }, 5000) + setDismissed(true); + onClose?.(); + }, 5000); } catch (error) { - console.error('Friendbot funding error:', error) - setFundingStatus('error') + console.error("Friendbot funding error:", error); + setFundingStatus("error"); const message = - error instanceof Error ? error.message : 'Failed to fund account. Please try again.' - setErrorMessage(message) + error instanceof Error + ? error.message + : "Failed to fund account. Please try again."; + setErrorMessage(message); - toast.error('Funding failed', { + toast.error("Funding failed", { description: message, - }) + }); } finally { - setFunding(false) + setFunding(false); } } @@ -116,17 +122,18 @@ export function TestnetFaucetBanner({ onClose }: TestnetFaucetBannerProps) { Your testnet account needs funding

- Fund your account with test XLM using Friendbot to start creating streams. + Fund your account with test XLM using Friendbot to start creating + streams.

- {fundingStatus === 'success' && ( + {fundingStatus === "success" && (

Account funded successfully! Reloading...

)} - {fundingStatus === 'error' && ( + {fundingStatus === "error" && (

{errorMessage} @@ -143,7 +150,7 @@ export function TestnetFaucetBanner({ onClose }: TestnetFaucetBannerProps) { className="gap-1.5 bg-amber-600 hover:bg-amber-700 text-white" > {funding && } - {funding ? 'Funding...' : 'Fund with Friendbot'} + {funding ? "Funding..." : "Fund with Friendbot"}