diff --git a/apps/web/package.json b/apps/web/package.json index a220afd..ed69936 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@tanstack/eslint-config": "^0.3.0", + "@tanstack/react-query-devtools": "^5.100.9", "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", diff --git a/apps/web/src/app/error-page.tsx b/apps/web/src/app/error-page.tsx new file mode 100644 index 0000000..305f8eb --- /dev/null +++ b/apps/web/src/app/error-page.tsx @@ -0,0 +1,21 @@ +type Props = { + onReset?: () => void +} + +export function ErrorPage({ onReset }: Props) { + return ( +
+

Something went wrong

+

+ An unexpected error occurred. You can try reloading the page or contact support if the + problem persists. +

+ +
+ ) +} diff --git a/apps/web/src/app/providers/QueryProvider.tsx b/apps/web/src/app/providers/QueryProvider.tsx index afbac1b..9ce38c5 100644 --- a/apps/web/src/app/providers/QueryProvider.tsx +++ b/apps/web/src/app/providers/QueryProvider.tsx @@ -1,7 +1,22 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import type { ReactNode } from "react" +import { toast } from "sonner" +import { parseSorobanError } from "@/lib/soroban/errors" + +function isAuthError(error: unknown): boolean { + const text = String(error).toLowerCase() + return ["401", "403", "unauthorized", "unauthenticated"].some((token) => text.includes(token)) +} export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError(error) { + if (isAuthError(error)) return + if (import.meta.env.DEV) console.error("[query error]", error) + toast.error(parseSorobanError(error)) + }, + }), defaultOptions: { queries: { staleTime: 1000 * 30, @@ -14,6 +29,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { return ( {children} + {import.meta.env.DEV && } ) } diff --git a/apps/web/src/app/providers/index.tsx b/apps/web/src/app/providers/index.tsx index 1da450f..7730c4d 100644 --- a/apps/web/src/app/providers/index.tsx +++ b/apps/web/src/app/providers/index.tsx @@ -1,9 +1,10 @@ -import { createContext, useCallback, useContext, useEffect, useRef } from "react" +import { Component, createContext, useCallback, useContext, useEffect, useRef } from "react" import { QueryProvider } from "./QueryProvider" -import type { ReactNode } from "react" +import type { ErrorInfo, ReactNode } from "react" import { useWalletStore } from "@/features/wallet/store/wallet-store" import { NETWORK } from "@/app/config/network" import { ThemeProvider } from "@/ui/theme-provider" +import { ErrorPage } from "@/app/error-page" export type WalletStatus = "disconnected" | "connecting" | "connected" | "error" @@ -97,12 +98,35 @@ export function WalletProvider({ children }: { children: ReactNode }) { ) } +type ErrorBoundaryState = { hasError: boolean } + +class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> { + state: ErrorBoundaryState = { hasError: false } + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack) + } + + render() { + if (this.state.hasError) { + return this.setState({ hasError: false })} /> + } + return this.props.children + } +} + export function AppProviders({ children }: { children: ReactNode }) { return ( - - - {children} - - + + + + {children} + + + ) } diff --git a/apps/web/src/features/trade/components/positions/PositionsList.tsx b/apps/web/src/features/trade/components/positions/PositionsList.tsx index d983840..e85af25 100644 --- a/apps/web/src/features/trade/components/positions/PositionsList.tsx +++ b/apps/web/src/features/trade/components/positions/PositionsList.tsx @@ -1,11 +1,12 @@ -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { useQueryClient } from "@tanstack/react-query" import { Skeleton } from "@workspace/ui/components/skeleton" import { Button } from "@workspace/ui/components/button" import { Badge } from "@workspace/ui/components/badge" -import { usePositions } from "../../hooks/usePositions" +import { usePositions } from "../../hooks/usePositions" +import { useFundingRate } from "../../hooks/useFundingRate" import { createDecreaseOrder } from "../../lib/stellar" -import type {Position} from "../../hooks/usePositions"; +import type { Position } from "../../hooks/usePositions" import { formatPct, formatUsd } from "@/shared/lib/format" import { queryKeys } from "../../lib/query-keys" import { useWalletStore } from "@/features/wallet/store/wallet-store" @@ -14,8 +15,40 @@ type Props = { onSelectPosition?: (position: Position) => void } +function formatCountdown(ms: number): string { + if (ms <= 0) return "0m" + const totalSeconds = Math.floor(ms / 1000) + const h = Math.floor(totalSeconds / 3600) + const m = Math.floor((totalSeconds % 3600) / 60) + return h > 0 ? `${h}h ${m}m` : `${m}m` +} + +function useFundingCountdown(nextEpochTs: number | undefined): string { + const [remaining, setRemaining] = useState(0) + const intervalRef = useRef | null>(null) + + useEffect(() => { + if (nextEpochTs === undefined) return + + function tick() { + setRemaining(Math.max(0, nextEpochTs! - Date.now())) + } + + tick() + intervalRef.current = setInterval(tick, 1000) + + return () => { + if (intervalRef.current !== null) clearInterval(intervalRef.current) + } + }, [nextEpochTs]) + + return formatCountdown(remaining) +} + export function PositionsList({ onSelectPosition }: Props) { const { data: positions = [], isLoading } = usePositions() + const { data: fundingRate } = useFundingRate() + const countdown = useFundingCountdown(fundingRate?.nextEpochTs) const account = useWalletStore((state) => state.address) const queryClient = useQueryClient() const [closing, setClosing] = useState(null) @@ -31,14 +64,16 @@ export function PositionsList({ onSelectPosition }: Props) { marketAddress: position.marketAddress, collateralToken: position.collateralToken, collateralDeltaAmount: position.collateralAmount, - sizeDeltaUsd: position.sizeUsd, // full close + sizeDeltaUsd: position.sizeUsd, // full close isLong: position.isLong, acceptablePrice: position.markPrice, orderType: "MarketDecrease", receiveToken: position.collateralToken, }) if (account) { - await queryClient.invalidateQueries({ queryKey: queryKeys.positions("stellar-mainnet", account) }) + await queryClient.invalidateQueries({ + queryKey: queryKeys.positions("stellar-mainnet", account), + }) } } finally { setClosing(null) @@ -74,6 +109,7 @@ export function PositionsList({ onSelectPosition }: Props) { Mark Liq. PnL + Next Funding @@ -108,6 +144,9 @@ export function PositionsList({ onSelectPosition }: Props) { {formatUsd(p.pnl)} ({formatPct(p.pnlPercent)}) + + {countdown} +