From 33a62ad9f543489d51a77affd1cb70bdd3e34149 Mon Sep 17 00:00:00 2001 From: Nikita Khromov Date: Fri, 5 Jun 2026 12:48:08 +0300 Subject: [PATCH 1/4] Added Dashboard page --- web-v2/src/App.tsx | 5 + web-v2/src/api/indexer/hooks.ts | 6 +- web-v2/src/components/AppLayout.tsx | 65 ++++-- web-v2/src/components/WalletButton.tsx | 18 ++ .../components/icons/ArrowSquareOutIcon.tsx | 23 ++ .../components/icons/ArrowSquareUpIcon.tsx | 24 ++ .../src/components/icons/ArrowsRotateIcon.tsx | 23 ++ web-v2/src/components/icons/BellIcon.tsx | 23 ++ .../icons/ChevronsExpandVerticalIcon.tsx | 23 ++ .../src/components/icons/CircleDashedIcon.tsx | 25 ++ web-v2/src/components/icons/LbtcIcon.tsx | 24 ++ web-v2/src/components/icons/UsdtIcon.tsx | 29 +++ web-v2/src/components/ui/OfferStatusBadge.tsx | 29 +++ web-v2/src/components/ui/UiDataRow.tsx | 23 ++ web-v2/src/components/ui/UiPagination.tsx | 75 ++++++ web-v2/src/constants/assets.ts | 20 ++ web-v2/src/constants/lending.ts | 9 + web-v2/src/constants/routes.ts | 1 + web-v2/src/lib/wallet-core/types.ts | 3 + web-v2/src/mocks/offers.ts | 208 +++++++++++++++++ web-v2/src/pages/Dashboard/BalanceCard.tsx | 57 +++++ web-v2/src/pages/Dashboard/BaseCard.tsx | 109 +++++++++ web-v2/src/pages/Dashboard/BorrowCard.tsx | 78 +++++++ web-v2/src/pages/Dashboard/OffersTable.tsx | 221 ++++++++++++++++++ web-v2/src/pages/Dashboard/OverviewStats.tsx | 71 ++++++ web-v2/src/pages/Dashboard/SupplyCard.tsx | 78 +++++++ web-v2/src/pages/Dashboard/index.tsx | 33 ++- web-v2/src/pages/Dashboard/useDashboard.ts | 194 +++++++++++++++ .../{Dashboard => WalletDemo}/WalletDemo.tsx | 0 web-v2/src/pages/WalletDemo/index.tsx | 11 + .../src/providers/wallet/WalletProvider.tsx | 8 + web-v2/src/providers/wallet/types.ts | 7 + web-v2/src/utils/balance.ts | 5 + web-v2/src/utils/format.ts | 35 +++ web-v2/src/utils/lending.ts | 9 + 35 files changed, 1553 insertions(+), 19 deletions(-) create mode 100644 web-v2/src/components/WalletButton.tsx create mode 100644 web-v2/src/components/icons/ArrowSquareOutIcon.tsx create mode 100644 web-v2/src/components/icons/ArrowSquareUpIcon.tsx create mode 100644 web-v2/src/components/icons/ArrowsRotateIcon.tsx create mode 100644 web-v2/src/components/icons/BellIcon.tsx create mode 100644 web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx create mode 100644 web-v2/src/components/icons/CircleDashedIcon.tsx create mode 100644 web-v2/src/components/icons/LbtcIcon.tsx create mode 100644 web-v2/src/components/icons/UsdtIcon.tsx create mode 100644 web-v2/src/components/ui/OfferStatusBadge.tsx create mode 100644 web-v2/src/components/ui/UiDataRow.tsx create mode 100644 web-v2/src/components/ui/UiPagination.tsx create mode 100644 web-v2/src/constants/assets.ts create mode 100644 web-v2/src/constants/lending.ts create mode 100644 web-v2/src/mocks/offers.ts create mode 100644 web-v2/src/pages/Dashboard/BalanceCard.tsx create mode 100644 web-v2/src/pages/Dashboard/BaseCard.tsx create mode 100644 web-v2/src/pages/Dashboard/BorrowCard.tsx create mode 100644 web-v2/src/pages/Dashboard/OffersTable.tsx create mode 100644 web-v2/src/pages/Dashboard/OverviewStats.tsx create mode 100644 web-v2/src/pages/Dashboard/SupplyCard.tsx create mode 100644 web-v2/src/pages/Dashboard/useDashboard.ts rename web-v2/src/pages/{Dashboard => WalletDemo}/WalletDemo.tsx (100%) create mode 100644 web-v2/src/pages/WalletDemo/index.tsx create mode 100644 web-v2/src/utils/balance.ts create mode 100644 web-v2/src/utils/format.ts create mode 100644 web-v2/src/utils/lending.ts diff --git a/web-v2/src/App.tsx b/web-v2/src/App.tsx index e66bcf8..ceb4de6 100644 --- a/web-v2/src/App.tsx +++ b/web-v2/src/App.tsx @@ -10,6 +10,7 @@ import BorrowPage from './pages/Borrow' import DashboardPage from './pages/Dashboard' import DesignSystemPage from './pages/DesignSystem' import SupplyPage from './pages/Supply' +import WalletDemoPage from './pages/WalletDemo' const router = createBrowserRouter([ { @@ -35,6 +36,10 @@ const router = createBrowserRouter([ path: RoutePath.DesignSystem, element: , }, + { + path: RoutePath.WalletDemo, + element: , + }, ] : []), ], diff --git a/web-v2/src/api/indexer/hooks.ts b/web-v2/src/api/indexer/hooks.ts index 15a5061..628094f 100644 --- a/web-v2/src/api/indexer/hooks.ts +++ b/web-v2/src/api/indexer/hooks.ts @@ -14,11 +14,15 @@ import { import { offersQueryKeys } from './queryKeys' import type { OfferDetails, OfferParticipant, OfferShort, OfferUtxo } from './schemas' -export function useOffers(params: ListOffersParams = {}): UseQueryResult { +export function useOffers( + params: ListOffersParams = {}, + options: { refetchInterval?: number } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.list(params), queryFn: ({ signal }) => fetchOffers(params, { signal }), staleTime: STALE_TIME_MS.medium, + refetchInterval: options.refetchInterval, }) } diff --git a/web-v2/src/components/AppLayout.tsx b/web-v2/src/components/AppLayout.tsx index ead0909..c71667a 100644 --- a/web-v2/src/components/AppLayout.tsx +++ b/web-v2/src/components/AppLayout.tsx @@ -1,36 +1,73 @@ +import { useCallback } from 'react' import { Link, Outlet } from 'react-router-dom' +import ArrowSquareOutIcon from '@/components/icons/ArrowSquareOutIcon' +import BellIcon from '@/components/icons/BellIcon' +import { UiButton } from '@/components/ui/UiButton' +import { WalletButton } from '@/components/WalletButton' import { env } from '@/constants/env' import { RoutePath } from '@/constants/routes' +const ABOUT_SIMPLICITY_URL = 'https://github.com/BlockstreamResearch/simplicity' + const NAV = [ { to: RoutePath.Dashboard, label: 'Dashboard' }, { to: RoutePath.Borrow, label: 'Borrow' }, { to: RoutePath.Supply, label: 'Supply' }, - ...(env.DEV ? [{ to: RoutePath.DesignSystem, label: 'System' }] : []), + ...(env.DEV + ? [ + { to: RoutePath.DesignSystem, label: 'System' }, + { to: RoutePath.WalletDemo, label: 'Wallet Demo' }, + ] + : []), ] export default function AppLayout() { + const openAbout = useCallback(() => { + window.open(ABOUT_SIMPLICITY_URL, '_blank', 'noopener,noreferrer') + }, []) + return ( -
-
-
-

Lending

-
- - - -
-

Network: {env.VITE_NETWORK}

-

API URL: {env.VITE_API_URL}

-

Esplora Base URL: {env.VITE_ESPLORA_BASE_URL}

+
+

Network: {env.VITE_NETWORK}

+

API URL: {env.VITE_API_URL}

+

Esplora Base URL: {env.VITE_ESPLORA_BASE_URL}

+
diff --git a/web-v2/src/components/WalletButton.tsx b/web-v2/src/components/WalletButton.tsx new file mode 100644 index 0000000..5294b00 --- /dev/null +++ b/web-v2/src/components/WalletButton.tsx @@ -0,0 +1,18 @@ +import { UiButton } from '@/components/ui/UiButton' +import { DEFAULT_WALLET_TYPE } from '@/lib/wallet-core/types' +import { useWallet } from '@/providers/wallet/useWallet' +import { truncateAddress } from '@/utils/format' + +export function WalletButton() { + const { connectionStatus, receiveAddress, connect } = useWallet() + + if (connectionStatus === 'ready' && receiveAddress) { + return {truncateAddress(receiveAddress)} + } + + return ( + connect(DEFAULT_WALLET_TYPE)}> + Connect Wallet + + ) +} diff --git a/web-v2/src/components/icons/ArrowSquareOutIcon.tsx b/web-v2/src/components/icons/ArrowSquareOutIcon.tsx new file mode 100644 index 0000000..02c886b --- /dev/null +++ b/web-v2/src/components/icons/ArrowSquareOutIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ArrowSquareOutIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ArrowSquareUpIcon.tsx b/web-v2/src/components/icons/ArrowSquareUpIcon.tsx new file mode 100644 index 0000000..c322322 --- /dev/null +++ b/web-v2/src/components/icons/ArrowSquareUpIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +export default function ArrowSquareUpIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ArrowsRotateIcon.tsx b/web-v2/src/components/icons/ArrowsRotateIcon.tsx new file mode 100644 index 0000000..a8ed7bd --- /dev/null +++ b/web-v2/src/components/icons/ArrowsRotateIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ArrowsRotateIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/BellIcon.tsx b/web-v2/src/components/icons/BellIcon.tsx new file mode 100644 index 0000000..db7d6b3 --- /dev/null +++ b/web-v2/src/components/icons/BellIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function BellIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx b/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx new file mode 100644 index 0000000..a08684c --- /dev/null +++ b/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ChevronsExpandVerticalIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/CircleDashedIcon.tsx b/web-v2/src/components/icons/CircleDashedIcon.tsx new file mode 100644 index 0000000..2b24b8a --- /dev/null +++ b/web-v2/src/components/icons/CircleDashedIcon.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' + +export default function CircleDashedIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/LbtcIcon.tsx b/web-v2/src/components/icons/LbtcIcon.tsx new file mode 100644 index 0000000..fc05e57 --- /dev/null +++ b/web-v2/src/components/icons/LbtcIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +// Liquid Bitcoin (LBTC) brand logo. Multi-color, so fills are intentional (not currentColor). +export default function LbtcIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/UsdtIcon.tsx b/web-v2/src/components/icons/UsdtIcon.tsx new file mode 100644 index 0000000..cd84aaf --- /dev/null +++ b/web-v2/src/components/icons/UsdtIcon.tsx @@ -0,0 +1,29 @@ +import type { SVGProps } from 'react' + +// Tether (USDT) brand logo. Multi-color, so fills are intentional (not currentColor). +export default function UsdtIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/ui/OfferStatusBadge.tsx b/web-v2/src/components/ui/OfferStatusBadge.tsx new file mode 100644 index 0000000..0bab938 --- /dev/null +++ b/web-v2/src/components/ui/OfferStatusBadge.tsx @@ -0,0 +1,29 @@ +import { Chip } from '@heroui/react' + +import type { OfferStatus } from '@/api/indexer/schemas' +import CircleDashedIcon from '@/components/icons/CircleDashedIcon' + +export type DisplayStatus = OfferStatus | 'expired' + +type ChipColor = 'success' | 'warning' | 'accent' | 'danger' | 'default' + +const STATUS_CHIP: Record = { + active: { color: 'success', label: 'Active' }, + pending: { color: 'warning', label: 'Pending' }, + repaid: { color: 'accent', label: 'Repaid' }, + liquidated: { color: 'danger', label: 'Liquidated' }, + cancelled: { color: 'default', label: 'Cancelled' }, + claimed: { color: 'default', label: 'Claimed' }, + unknown: { color: 'default', label: 'Unknown' }, + expired: { color: 'default', label: 'Expired' }, +} + +export function OfferStatusBadge({ status }: { status: DisplayStatus }) { + const { color, label } = STATUS_CHIP[status] + return ( + + + {label} + + ) +} diff --git a/web-v2/src/components/ui/UiDataRow.tsx b/web-v2/src/components/ui/UiDataRow.tsx new file mode 100644 index 0000000..88d28aa --- /dev/null +++ b/web-v2/src/components/ui/UiDataRow.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from '@heroui/react' +import type { ReactNode } from 'react' + +export function UiDataRows({ children }: { children: ReactNode }) { + return
{children}
+} + +export function UiDataRow({ + label, + value, + isLoading, +}: { + label: string + value: ReactNode + isLoading?: boolean +}) { + return ( +
+ {label} + {isLoading ? : {value}} +
+ ) +} diff --git a/web-v2/src/components/ui/UiPagination.tsx b/web-v2/src/components/ui/UiPagination.tsx new file mode 100644 index 0000000..44dd206 --- /dev/null +++ b/web-v2/src/components/ui/UiPagination.tsx @@ -0,0 +1,75 @@ +import { Pagination } from '@heroui/react' +import type { ReactNode } from 'react' + +function buildPageList(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1) + if (current <= 3 || current >= total - 2) { + return [1, 2, 3, 'ellipsis', total - 2, total - 1, total] + } + return [1, 'ellipsis', current - 1, current, current + 1, 'ellipsis', total] +} + +interface UiPaginationProps { + currentPage: number + onPageChange: (page: number) => void + summary?: ReactNode + // Known total — enables page number list + pageCount?: number + // Unknown total (server-side without count) — only prev/next + hasNextPage?: boolean +} + +export function UiPagination({ + currentPage, + pageCount, + hasNextPage, + onPageChange, + summary, +}: UiPaginationProps) { + const isLastPage = pageCount !== undefined ? currentPage >= pageCount : !hasNextPage + + return ( + + {summary && {summary}} + + + onPageChange(currentPage - 1)} + > + + Previous + + + + {pageCount !== undefined && + buildPageList(currentPage, pageCount).map((p, idx) => + p === 'ellipsis' ? ( + + + + ) : ( + + onPageChange(p)}> + {p} + + + ), + )} + + {pageCount === undefined && ( + + {currentPage} + + )} + + + onPageChange(currentPage + 1)}> + Next + + + + + + ) +} diff --git a/web-v2/src/constants/assets.ts b/web-v2/src/constants/assets.ts new file mode 100644 index 0000000..726a734 --- /dev/null +++ b/web-v2/src/constants/assets.ts @@ -0,0 +1,20 @@ +import type { NetworkName } from '@/constants/env' + +// Asset IDs per network — Liquid mainnet / testnet / regtest. +// Use as `ASSET_ID[env.VITE_NETWORK].LBTC`. +export const ASSET_ID: Record = { + liquid: { + LBTC: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d', + USDT: 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2', + }, + liquidtestnet: { + LBTC: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + USDT: 'f3d1ec678811398cd2ae277cbe3849c6f6dbd72c74bc542f7c4b11ff0e820958', + }, + regtest: { + LBTC: '5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225', + USDT: '25b17682b0e4f7b0711de7e8ee2e33cd01d65680eed82cce1af84cfbdde30064', + }, +} + +export const ASSET_DECIMALS = { LBTC: 8, USDT: 2 } as const diff --git a/web-v2/src/constants/lending.ts b/web-v2/src/constants/lending.ts new file mode 100644 index 0000000..8748f03 --- /dev/null +++ b/web-v2/src/constants/lending.ts @@ -0,0 +1,9 @@ +// Liquid ~1 block/minute → 144 blocks ≈ 2.4h. +// Active loans whose term left is under this threshold are "nearing deadline". +export const REPAYMENT_DUE_THRESHOLD_BLOCKS = 144 + +// Dashboard data refresh interval. +export const DASHBOARD_REFETCH_INTERVAL_MS = 30_000 + +// Pagination. +export const TABLE_PAGE_SIZE = 10 diff --git a/web-v2/src/constants/routes.ts b/web-v2/src/constants/routes.ts index 9f02327..4df8f69 100644 --- a/web-v2/src/constants/routes.ts +++ b/web-v2/src/constants/routes.ts @@ -3,6 +3,7 @@ export const RoutePath = { Borrow: '/borrow', Supply: '/supply', DesignSystem: '/design-system', + WalletDemo: '/wallet-demo', } as const export type RoutePath = (typeof RoutePath)[keyof typeof RoutePath] diff --git a/web-v2/src/lib/wallet-core/types.ts b/web-v2/src/lib/wallet-core/types.ts index 9457cfb..cedd18d 100644 --- a/web-v2/src/lib/wallet-core/types.ts +++ b/web-v2/src/lib/wallet-core/types.ts @@ -1,5 +1,8 @@ export type WalletType = 'Wpkh' | 'ShWpkh' +/** Default wallet type used when kicking off the connect flow. */ +export const DEFAULT_WALLET_TYPE: WalletType = 'Wpkh' + /** Raw JADE_STATE values from getVersion() */ export type JadeConnectionState = 'LOCKED' | 'READY' | 'UNINIT' | 'TEMP' diff --git a/web-v2/src/mocks/offers.ts b/web-v2/src/mocks/offers.ts new file mode 100644 index 0000000..997ff28 --- /dev/null +++ b/web-v2/src/mocks/offers.ts @@ -0,0 +1,208 @@ +import type { OfferShort } from '@/api/indexer/schemas' + +export const MOCK_OFFERS: OfferShort[] = [ + { + id: '6adfbb12-6498-439d-bc49-4cdf4e27be9b', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 100, + loan_expiration_time: 2452825, + created_at_height: 2451829, + created_at_txid: 'ac5bebd1399cb885f9ffb9a3089373a53dfcacb6f6a9ce851ff755cae095d60d', + }, + { + id: 'bba212f1-81ce-4a0b-a1cf-3f0569c14580', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 1200, + loan_expiration_time: 2431851, + created_at_height: 2431756, + created_at_txid: '94ee9be637e1de238318486afb6ecbc62a14efa961fd28bd8f4599d8ea79281c', + }, + { + id: '4bf5995a-986e-4282-a34b-922142af5446', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 300, + loan_expiration_time: 2430181, + created_at_height: 2430085, + created_at_txid: '0ca30bb5c3e2270ab075a23332d6ab7cb82cbfc5572d95b1c431c497bf76a933', + }, + { + id: '821a9026-1d65-44e9-986f-92176e306f56', + status: 'cancelled', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 1500n, + principal_amount: 1000n, + interest_rate: 300, + loan_expiration_time: 2430110, + created_at_height: 2429979, + created_at_txid: '0ed73b70aad6d8c65ac578cefd40060d14205c23d3ca013553f1a337c60b69a4', + }, + { + id: '3a5dccd1-2a02-4977-a94c-d851d7867349', + status: 'cancelled', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 100, + loan_expiration_time: 2427291, + created_at_height: 2427262, + created_at_txid: '4a100b591e76b0f296750e99cee0f517adc98f01d045d29e67e4ecefd9597bfd', + }, + { + id: '274857d4-fe02-4ed4-9433-4a652b95fe71', + status: 'cancelled', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 100, + loan_expiration_time: 2427308, + created_at_height: 2427212, + created_at_txid: '9921fdea4cdf37f6d53d9504811dbf3211ac5837c4acfc3f1e18979e7cc08941', + }, + { + id: '75191635-b084-410e-993e-506320c37e95', + status: 'cancelled', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 2000n, + principal_amount: 1000n, + interest_rate: 100, + loan_expiration_time: 2427200, + created_at_height: 2427201, + created_at_txid: '2f150a6816204489a6b09810e7757a9a30185795b337cfae0eb19738c002e241', + }, + { + id: '305041d3-997c-44a2-8df5-cdc8c7225d2b', + status: 'liquidated', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 5000n, + principal_amount: 2000n, + interest_rate: 900, + loan_expiration_time: 2405715, + created_at_height: 2427169, + created_at_txid: 'cd6bb5f5264b569315a39fdbd2adad45c0486fbe41958501adb6053fad0cc3c4', + }, + { + id: 'c56c168d-2685-4c0f-a959-6322d1b4e5dc', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 1000n, + principal_amount: 300n, + interest_rate: 100, + loan_expiration_time: 2416905, + created_at_height: 2416816, + created_at_txid: '0485bef0d6ab612aa03d9493719dbe047ec9c74c4bb06cd3283c8cada8e81496', + }, + { + id: 'ae8d0045-07ac-4070-9dd1-052efecd846f', + status: 'active', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 300n, + principal_amount: 800n, + interest_rate: 1000, + loan_expiration_time: 2401660, + created_at_height: 2401487, + created_at_txid: '111361b57c00aca4aef871da3c9ef6020433aec043410813bb8bcdcbefadcd06', + }, + { + id: 'cc2d16a5-2ee0-4913-a505-1df9429cacf8', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 300n, + principal_amount: 800n, + interest_rate: 1000, + loan_expiration_time: 2398138, + created_at_height: 2397142, + created_at_txid: 'c6216de3d5e604b74283f16071207db44c054fd2d586b7d37ca54e924888e6d2', + }, + { + id: 'c9714216-eaff-423f-a4ed-09c0187389d2', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 200n, + principal_amount: 800n, + interest_rate: 500, + loan_expiration_time: 2398725, + created_at_height: 2396730, + created_at_txid: 'bf1c2cd07c2e33baaa8b19ed5e6f9542ae6598b9bbaaf0d4d5bb7ac42b07b52b', + }, + { + id: 'a2c7c4c5-6ac8-4aa2-a8ad-da969e111c24', + status: 'pending', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 30000n, + principal_amount: 5000n, + interest_rate: 1000, + loan_expiration_time: 2397673, + created_at_height: 2395676, + created_at_txid: 'd726ff4c404e3953c0062876e45f9c3d4210021ecdda519701c7e3bb64706fe5', + }, + { + id: '24028477-1503-4278-a829-8f2fc6f6db38', + status: 'active', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 1000n, + principal_amount: 100n, + interest_rate: 1500, + loan_expiration_time: 2381994, + created_at_height: 2380998, + created_at_txid: '4440d0c4f7d4107775b38a6d00a7e482ff162386da9f0fcb9827cd6bb901904b', + }, + { + id: 'e0aa61a4-d77d-448c-ab21-f7f2c9b2a297', + status: 'liquidated', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 10000n, + principal_amount: 500n, + interest_rate: 300, + loan_expiration_time: 2380947, + created_at_height: 2380751, + created_at_txid: 'f01c57b8cd9192a68a5239622d3581c30a282960e664a54445c04d229c7f495a', + }, + { + id: '7cc0ecc1-1f95-4484-89cd-1c8aa2cecf4e', + status: 'claimed', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', + collateral_amount: 1000n, + principal_amount: 300n, + interest_rate: 100, + loan_expiration_time: 2389644, + created_at_height: 2379675, + created_at_txid: 'b6631a8dfc293e97affcdf68428695927cde5f46d73d957e4fede0bc2ed1f7c5', + }, + { + id: 'df05d20f-2e5b-4c80-ad97-315b791b6670', + status: 'pending', + collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + principal_asset: '5add42b2dfeea8fe664bad6073e320518bb2be5c88357973091e9e278ce7084c', + collateral_amount: 500n, + principal_amount: 3500n, + interest_rate: 500, + loan_expiration_time: 2387644, + created_at_height: 2378647, + created_at_txid: 'c5e3777e0eb669a058607060c9f5b6966a30e14844c6729e2ecc0c019bc05ebf', + }, +] diff --git a/web-v2/src/pages/Dashboard/BalanceCard.tsx b/web-v2/src/pages/Dashboard/BalanceCard.tsx new file mode 100644 index 0000000..effdf46 --- /dev/null +++ b/web-v2/src/pages/Dashboard/BalanceCard.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' + +import { WalletButton } from '@/components/WalletButton' + +import { CardError, CardHeader, CardShell } from './BaseCard' + +export function BalanceCard({ + icon, + title, + subtitle, + isLoading, + isReady, + error, + connectMessage, + errorMessage, + onRetry, + balance, + fiat, + children, +}: { + icon: ReactNode + title: string + subtitle: string + isLoading: boolean + isReady: boolean + error: Error | null + connectMessage: string + errorMessage: string + onRetry: () => void + balance: ReactNode + fiat?: string + children: ReactNode +}) { + const showBody = isReady && !error + return ( + + + {!isReady ? ( +
+

{connectMessage}

+ +
+ ) : error ? ( + + ) : ( + children + )} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/BaseCard.tsx b/web-v2/src/pages/Dashboard/BaseCard.tsx new file mode 100644 index 0000000..8b2bcd5 --- /dev/null +++ b/web-v2/src/pages/Dashboard/BaseCard.tsx @@ -0,0 +1,109 @@ +import { Skeleton } from '@heroui/react' +import type { ReactNode } from 'react' + +import LbtcIcon from '@/components/icons/LbtcIcon' +import UsdtIcon from '@/components/icons/UsdtIcon' +import { UiButton } from '@/components/ui/UiButton' + +export function CardShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +const UNIT_LOGO: Record = { + LBTC: , + USDT: , +} + +export function AssetAmount({ value, unit }: { value: string; unit: string }) { + return ( + <> + {value} + + {UNIT_LOGO[unit]} + {unit} + + + ) +} + +export function CardHeader({ + icon, + title, + subtitle, + balance, + fiat, + isLoading, +}: { + icon: ReactNode + title: string + subtitle: string + balance: ReactNode + fiat?: string + isLoading?: boolean +}) { + return ( +
+
+ {icon} +

{title}

+
+

{subtitle}

+ {isLoading ? ( + + ) : ( +

{balance}

+ )} + {!isLoading && fiat &&

{fiat}

} +
+ ) +} + +export function CardAlert({ + variant, + title, + description, + actionLabel, + onAction, + isDisabled, +}: { + variant: 'warning' | 'accent' + title: string + description: string + actionLabel: string + onAction?: () => void + isDisabled?: boolean +}) { + const titleColor = variant === 'warning' ? 'text-warning' : 'text-foreground' + return ( +
+
+

{title}

+

{description}

+
+ + {actionLabel} + +
+ ) +} + +export function CardError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+

{message}

+ + Retry + +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/BorrowCard.tsx b/web-v2/src/pages/Dashboard/BorrowCard.tsx new file mode 100644 index 0000000..32c9d45 --- /dev/null +++ b/web-v2/src/pages/Dashboard/BorrowCard.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from 'react-router-dom' + +import CoinsIcon from '@/components/icons/CoinsIcon' +import { UiButton } from '@/components/ui/UiButton' +import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' +import { ASSET_DECIMALS } from '@/constants/assets' +import { RoutePath } from '@/constants/routes' +import { formatAsset, truncateAddress, USD_PLACEHOLDER } from '@/utils/format' + +import { BalanceCard } from './BalanceCard' +import { AssetAmount, CardAlert } from './BaseCard' +import type { DashboardBorrows } from './useDashboard' + +interface BorrowCardProps { + data: DashboardBorrows + isLoading: boolean + isReady: boolean + onRetry: () => void +} + +export function BorrowCard({ data, isLoading, isReady, onRetry }: BorrowCardProps) { + const navigate = useNavigate() + const { stats, nearExpiryOffers } = data + const alertOffer = nearExpiryOffers[0] + + return ( + } + title='Your Borrows' + subtitle='Complete Balance LBTC' + isLoading={isLoading} + isReady={isReady} + error={data.error} + connectMessage='Connect your wallet to view your borrows.' + errorMessage='Failed to load your borrows.' + onRetry={onRetry} + balance={} + fiat={USD_PLACEHOLDER} + > + + + + + + + + {alertOffer && ( + + )} + + navigate(RoutePath.Borrow)}> + Borrow + + + ) +} diff --git a/web-v2/src/pages/Dashboard/OffersTable.tsx b/web-v2/src/pages/Dashboard/OffersTable.tsx new file mode 100644 index 0000000..508099c --- /dev/null +++ b/web-v2/src/pages/Dashboard/OffersTable.tsx @@ -0,0 +1,221 @@ +import { Skeleton, Table } from '@heroui/react' +import { useMemo, useState } from 'react' + +import { useBlockHeight } from '@/api/esplora/hooks' +import { useOffers } from '@/api/indexer/hooks' +import type { OfferShort } from '@/api/indexer/schemas' +import ArrowsRotateIcon from '@/components/icons/ArrowsRotateIcon' +import ChevronsExpandVerticalIcon from '@/components/icons/ChevronsExpandVerticalIcon' +import { OfferStatusBadge } from '@/components/ui/OfferStatusBadge' +import { UiButton } from '@/components/ui/UiButton' +import { UiPagination } from '@/components/ui/UiPagination' +import { ASSET_DECIMALS } from '@/constants/assets' +import { env } from '@/constants/env' +import { DASHBOARD_REFETCH_INTERVAL_MS, TABLE_PAGE_SIZE } from '@/constants/lending' +import { MOCK_OFFERS } from '@/mocks/offers' +import { formatAsset, formatTermLeft } from '@/utils/format' +import { bpsToPercent, calcInterest } from '@/utils/lending' + +import type { DisplayOffer } from './useDashboard' + +type SortCol = 'collateral_amount' | 'principal_amount' | 'earn' | 'interest_rate' | 'termLeft' +type SortState = { col: SortCol; dir: 'asc' | 'desc' } | null + +const FETCH_LIMIT = TABLE_PAGE_SIZE + 1 // n+1 to detect next page without total count + +function toDisplayOffer(offer: OfferShort, currentBlockHeight: number): DisplayOffer { + const termLeft = offer.loan_expiration_time - currentBlockHeight + const displayStatus = offer.status === 'pending' && termLeft <= 0 ? 'expired' : offer.status + return { + ...offer, + termLeft, + displayStatus, + earn: calcInterest(offer.principal_amount, offer.interest_rate), + } +} + +function SortableHeader({ + label, + col, + sort, + onSort, +}: { + label: string + col: SortCol + sort: SortState + onSort: (col: SortCol) => void +}) { + const active = sort?.col === col + return ( + + ) +} + +export function OffersTable() { + const [sort, setSort] = useState(null) + const [page, setPage] = useState(1) + + const offset = (page - 1) * TABLE_PAGE_SIZE + + const offersQuery = useOffers( + { limit: FETCH_LIMIT, offset }, + { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS }, + ) + const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) + const currentBlockHeight = blockHeightQuery.data ?? 0 + + // In DEV, slice mock data to simulate server-side pagination + const rawBatch: OfferShort[] = useMemo(() => { + if (env.DEV) return MOCK_OFFERS.slice(offset, offset + FETCH_LIMIT) + return offersQuery.data ?? [] + }, [offersQuery.data, offset]) + + const hasNextPage = rawBatch.length > TABLE_PAGE_SIZE + const pageOffers = rawBatch.slice(0, TABLE_PAGE_SIZE) + + const displayOffers = useMemo( + () => pageOffers.map(o => toDisplayOffer(o, currentBlockHeight)), + [pageOffers, currentBlockHeight], + ) + + const handleSort = (col: SortCol) => { + setSort(prev => + prev?.col === col ? { col, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' }, + ) + } + + // Sort applies to current page only (server-side sort not yet supported by API) + const sorted = useMemo(() => { + if (!sort) return displayOffers + const dir = sort.dir === 'asc' ? 1 : -1 + return [...displayOffers].sort((a, b) => { + const av = a[sort.col] + const bv = b[sort.col] + if (av < bv) return -dir + if (av > bv) return dir + return 0 + }) + }, [displayOffers, sort]) + + const isLoading = (env.DEV ? false : offersQuery.isLoading) || blockHeightQuery.isLoading + const error = env.DEV ? null : (offersQuery.error as Error | null) + const handleRetry = () => { + void offersQuery.refetch() + void blockHeightQuery.refetch() + } + + return ( +
+
+ +

Most recent Borrow Offers

+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : error ? ( +
+

{error.message || 'Failed to load offers.'}

+ + Retry + +
+ ) : sorted.length === 0 ? ( +

No offers found

+ ) : ( + + + + + + + + + + + + + + + + + + + + Status + + + {offer => ( + + + {formatAsset(offer.collateral_amount, ASSET_DECIMALS.LBTC)} + + + {formatAsset(offer.principal_amount, ASSET_DECIMALS.USDT)} + + {formatAsset(offer.earn, ASSET_DECIMALS.USDT)} + {bpsToPercent(offer.interest_rate)} + {formatTermLeft(offer.termLeft)} + + + + + )} + + + + + { + setPage(p) + setSort(null) + }} + /> + +
+ )} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/OverviewStats.tsx b/web-v2/src/pages/Dashboard/OverviewStats.tsx new file mode 100644 index 0000000..5c7865b --- /dev/null +++ b/web-v2/src/pages/Dashboard/OverviewStats.tsx @@ -0,0 +1,71 @@ +import { Skeleton } from '@heroui/react' +import { useMemo } from 'react' + +import { ASSET_DECIMALS } from '@/constants/assets' +import { formatAsset, USD_PLACEHOLDER } from '@/utils/format' +import { bpsToPercent } from '@/utils/lending' + +import { AssetAmount } from './BaseCard' +import type { DashboardOverview } from './useDashboard' + +interface OverviewStatsProps { + data: DashboardOverview + isLoading: boolean +} + +interface OverviewStat { + label: string + value: string + unit?: string + // No price oracle yet, so monetary tiles fall back to USD_PLACEHOLDER. + fiat?: string +} + +const GRID = 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 lg:gap-6' + +function Tile({ label, value, unit, fiat, isLoading }: OverviewStat & { isLoading?: boolean }) { + return ( +
+

{label}

+ {isLoading ? ( + + ) : ( +
+

{unit ? : value}

+ {fiat &&

{fiat}

} +
+ )} +
+ ) +} + +export function OverviewStats({ data, isLoading }: OverviewStatsProps) { + const stats = useMemo( + () => [ + { + label: 'Collateral Locked', + value: formatAsset(data.totalCollateral, ASSET_DECIMALS.LBTC), + unit: 'LBTC', + fiat: USD_PLACEHOLDER, + }, + { + label: 'Borrowings', + value: formatAsset(data.totalBorrowings, ASSET_DECIMALS.USDT), + unit: 'USDT', + fiat: USD_PLACEHOLDER, + }, + { label: 'Average APR', value: bpsToPercent(data.avgApr) }, + { label: 'Active Loans', value: String(data.activeLoans) }, + { label: 'Pending Offers', value: String(data.pendingOffers) }, + ], + [data], + ) + + return ( +
+ {stats.map(stat => ( + + ))} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/SupplyCard.tsx b/web-v2/src/pages/Dashboard/SupplyCard.tsx new file mode 100644 index 0000000..ff99edc --- /dev/null +++ b/web-v2/src/pages/Dashboard/SupplyCard.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from 'react-router-dom' + +import ArrowSquareUpIcon from '@/components/icons/ArrowSquareUpIcon' +import { UiButton } from '@/components/ui/UiButton' +import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' +import { ASSET_DECIMALS } from '@/constants/assets' +import { RoutePath } from '@/constants/routes' +import { formatAsset, truncateAddress, USD_PLACEHOLDER } from '@/utils/format' + +import { BalanceCard } from './BalanceCard' +import { AssetAmount, CardAlert } from './BaseCard' +import type { DashboardSupply } from './useDashboard' + +interface SupplyCardProps { + data: DashboardSupply + isLoading: boolean + isReady: boolean + onRetry: () => void +} + +export function SupplyCard({ data, isLoading, isReady, onRetry }: SupplyCardProps) { + const navigate = useNavigate() + const { stats, claimableOffers } = data + const alertOffer = claimableOffers[0] + + return ( + } + title='Your Supply' + subtitle='Complete Balance USDT' + isLoading={isLoading} + isReady={isReady} + error={data.error} + connectMessage='Connect your wallet to view your supply.' + errorMessage='Failed to load your supply.' + onRetry={onRetry} + balance={} + fiat={USD_PLACEHOLDER} + > + + + + + + + + {alertOffer && ( + + )} + + navigate(RoutePath.Supply)}> + Supply + + + ) +} diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index 0d4bc02..75e135a 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,10 +1,35 @@ -import { WalletDemo } from './WalletDemo' +import { BorrowCard } from './BorrowCard' +import { OffersTable } from './OffersTable' +import { OverviewStats } from './OverviewStats' +import { SupplyCard } from './SupplyCard' +import { useDashboard } from './useDashboard' export default function DashboardPage() { + const { overview, borrows, supply, isLoading, isReady, refetch } = useDashboard() + return ( -
-

Dashboard

- +
+
+

General Overview

+ +
+ +
+ + +
+ +
) } diff --git a/web-v2/src/pages/Dashboard/useDashboard.ts b/web-v2/src/pages/Dashboard/useDashboard.ts new file mode 100644 index 0000000..5899b54 --- /dev/null +++ b/web-v2/src/pages/Dashboard/useDashboard.ts @@ -0,0 +1,194 @@ +import { useCallback, useMemo } from 'react' + +import { useBlockHeight } from '@/api/esplora/hooks' +import { useOfferIdsByBorrowerPubkey, useOfferIdsByScript, useOffers } from '@/api/indexer/hooks' +import type { OfferShort } from '@/api/indexer/schemas' +import type { DisplayStatus } from '@/components/ui/OfferStatusBadge' +import { ASSET_ID } from '@/constants/assets' +import { env } from '@/constants/env' +import { DASHBOARD_REFETCH_INTERVAL_MS, REPAYMENT_DUE_THRESHOLD_BLOCKS } from '@/constants/lending' +import { MOCK_OFFERS } from '@/mocks/offers' +import { useWallet } from '@/providers/wallet/useWallet' +import { getAssetBalance } from '@/utils/balance' +import { calcInterest } from '@/utils/lending' + +const NETWORK_ASSETS = ASSET_ID[env.VITE_NETWORK] + +export interface DisplayOffer extends OfferShort { + termLeft: number + displayStatus: DisplayStatus + earn: bigint // total interest, precomputed for sorting +} + +export interface DashboardOverview { + totalCollateral: bigint + totalBorrowings: bigint + avgApr: number + activeLoans: number + pendingOffers: number +} + +export interface BorrowStats { + lockedCollateral: bigint + borrowings: bigint + activeLoans: number + pendingOffers: number + toRepay: number +} + +export interface SupplyStats { + suppliedLoans: bigint // total principal_amount across all user's supply offers (USDT) + interestOutstanding: bigint + activeLoans: number + repaidToClaim: number +} + +export interface DashboardBorrows { + balance: bigint + stats: BorrowStats + nearExpiryOffers: DisplayOffer[] + isLoading: boolean + error: Error | null +} + +export interface DashboardSupply { + balance: bigint + stats: SupplyStats + claimableOffers: DisplayOffer[] + isLoading: boolean + error: Error | null +} + +export function useDashboard() { + const { connectionStatus, balances, xOnlyPubkey, scriptPubkey } = useWallet() + const isReady = connectionStatus === 'ready' + + const offersQuery = useOffers({}, { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS }) + const borrowerIdsQuery = useOfferIdsByBorrowerPubkey(xOnlyPubkey ?? '') + const supplyIdsQuery = useOfferIdsByScript(scriptPubkey ?? '') + const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) + + const currentBlockHeight = blockHeightQuery.data ?? 0 + + const allOffers = useMemo( + () => (env.DEV ? MOCK_OFFERS : (offersQuery.data ?? [])), + [offersQuery.data], + ) + + const displayOffers = useMemo( + () => + allOffers.map(offer => { + const termLeft = offer.loan_expiration_time - currentBlockHeight + const displayStatus = offer.status === 'pending' && termLeft <= 0 ? 'expired' : offer.status + return { + ...offer, + termLeft, + displayStatus, + earn: calcInterest(offer.principal_amount, offer.interest_rate), + } + }), + [allOffers, currentBlockHeight], + ) + + const offerById = useMemo(() => { + const map = new Map() + for (const offer of displayOffers) map.set(offer.id, offer) + return map + }, [displayOffers]) + + const overview = useMemo(() => { + const active = displayOffers.filter(o => o.status === 'active') + const totalCollateral = active.reduce((acc, o) => acc + o.collateral_amount, 0n) + const totalBorrowings = active.reduce((acc, o) => acc + o.principal_amount, 0n) + const avgApr = active.length + ? active.reduce((acc, o) => acc + o.interest_rate, 0) / active.length + : 0 + return { + totalCollateral, + totalBorrowings, + avgApr, + activeLoans: active.length, + pendingOffers: displayOffers.filter(o => o.status === 'pending').length, + } + }, [displayOffers]) + + const lbtcBalance = getAssetBalance(balances, NETWORK_ASSETS.LBTC) + const usdtBalance = getAssetBalance(balances, NETWORK_ASSETS.USDT) + + const borrows = useMemo(() => { + const ids = borrowerIdsQuery.data ?? [] + const mine = ids.map(id => offerById.get(id)).filter((o): o is DisplayOffer => !!o) + const active = mine.filter(o => o.status === 'active') + const pending = mine.filter(o => o.status === 'pending') + const nearExpiryOffers = active.filter( + o => o.termLeft > 0 && o.termLeft < REPAYMENT_DUE_THRESHOLD_BLOCKS, + ) + return { + balance: lbtcBalance, + stats: { + lockedCollateral: active.reduce((acc, o) => acc + o.collateral_amount, 0n), + borrowings: active.reduce((acc, o) => acc + o.principal_amount, 0n), + activeLoans: active.length, + pendingOffers: pending.length, + toRepay: nearExpiryOffers.length, + }, + nearExpiryOffers, + isLoading: isReady && borrowerIdsQuery.isLoading, + error: borrowerIdsQuery.error, + } + }, [ + isReady, + lbtcBalance, + borrowerIdsQuery.data, + borrowerIdsQuery.isLoading, + borrowerIdsQuery.error, + offerById, + ]) + + const supply = useMemo(() => { + const ids = supplyIdsQuery.data ?? [] + const mine = ids.map(id => offerById.get(id)).filter((o): o is DisplayOffer => !!o) + const active = mine.filter(o => o.status === 'active') + const claimableOffers = mine.filter(o => o.status === 'repaid') + return { + balance: usdtBalance, + stats: { + suppliedLoans: mine.reduce((acc, o) => acc + o.principal_amount, 0n), + interestOutstanding: active.reduce((acc, o) => acc + o.earn, 0n), + activeLoans: active.length, + repaidToClaim: claimableOffers.length, + }, + claimableOffers, + isLoading: isReady && supplyIdsQuery.isLoading, + error: supplyIdsQuery.error, + } + }, [ + isReady, + usdtBalance, + supplyIdsQuery.data, + supplyIdsQuery.isLoading, + supplyIdsQuery.error, + offerById, + ]) + + const offersRefetch = offersQuery.refetch + const borrowerIdsRefetch = borrowerIdsQuery.refetch + const supplyIdsRefetch = supplyIdsQuery.refetch + const blockHeightRefetch = blockHeightQuery.refetch + + const refetch = useCallback(() => { + void offersRefetch() + void borrowerIdsRefetch() + void supplyIdsRefetch() + void blockHeightRefetch() + }, [offersRefetch, borrowerIdsRefetch, supplyIdsRefetch, blockHeightRefetch]) + + return { + overview, + borrows, + supply, + isReady, + isLoading: isReady && (offersQuery.isLoading || blockHeightQuery.isLoading), + refetch, + } +} diff --git a/web-v2/src/pages/Dashboard/WalletDemo.tsx b/web-v2/src/pages/WalletDemo/WalletDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/WalletDemo.tsx rename to web-v2/src/pages/WalletDemo/WalletDemo.tsx diff --git a/web-v2/src/pages/WalletDemo/index.tsx b/web-v2/src/pages/WalletDemo/index.tsx new file mode 100644 index 0000000..f359abe --- /dev/null +++ b/web-v2/src/pages/WalletDemo/index.tsx @@ -0,0 +1,11 @@ +import { WalletDemo } from './WalletDemo' + +// DEV-only page for manually testing wallet connect / sign / broadcast flows. +export default function WalletDemoPage() { + return ( +
+

Wallet Demo

+ +
+ ) +} diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index ede4e30..552efde 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -180,6 +180,11 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const balances = await syncBalances(wollet, esploraClient) + const address = wollet.address().address() + const receiveAddress = address.toString() + const scriptPubkey = address.scriptPubkey().toString() + const xOnlyPubkey = (await connector.getXOnlyPublicKey?.()) ?? null + setState(s => ({ ...s, connectionStatus: 'ready', @@ -187,6 +192,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { error: null, isError: false, balances, + receiveAddress, + scriptPubkey, + xOnlyPubkey, })) } catch (err) { const error = err instanceof Error ? err.message : String(err) diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts index ca626c6..4d22d88 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -34,6 +34,10 @@ export interface WalletState { connectorId: string | null walletType: WalletType | null balances: Record + // Resolved once on connect; null until ready. + receiveAddress: string | null + scriptPubkey: string | null + xOnlyPubkey: string | null syncing: boolean usbDeviceDetected: boolean /** Last error message. Persists even after isError is cleared. */ @@ -47,6 +51,9 @@ export const INITIAL_WALLET_STATE: WalletState = { connectorId: null, walletType: null, balances: {}, + receiveAddress: null, + scriptPubkey: null, + xOnlyPubkey: null, syncing: false, usbDeviceDetected: false, error: null, diff --git a/web-v2/src/utils/balance.ts b/web-v2/src/utils/balance.ts new file mode 100644 index 0000000..b0f099d --- /dev/null +++ b/web-v2/src/utils/balance.ts @@ -0,0 +1,5 @@ +// Wallet balances arrive as asset id → satoshis string. 0 for missing. +export function getAssetBalance(balances: Record, assetId: string): bigint { + const raw = balances[assetId] + return raw ? BigInt(raw) : 0n +} diff --git a/web-v2/src/utils/format.ts b/web-v2/src/utils/format.ts new file mode 100644 index 0000000..5de381a --- /dev/null +++ b/web-v2/src/utils/format.ts @@ -0,0 +1,35 @@ +const MINUTES_PER_BLOCK = 1 // Liquid ~1 min/block +const MINUTES_PER_HOUR = 60 +const MINUTES_PER_DAY = 1440 + +// satoshis → grouped decimal string, trailing zeros trimmed. +export function formatAsset(amount: bigint, decimals: number): string { + const negative = amount < 0n + const abs = negative ? -amount : amount + const base = 10n ** BigInt(decimals) + const whole = abs / base + const frac = abs % base + + const wholeStr = whole.toLocaleString('en-US') + const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, '') + + const out = fracStr ? `${wholeStr}.${fracStr}` : wholeStr + return negative ? `-${out}` : out +} + +// blocks remaining → "Expired" / "~Xm" / "~Xh" / ">Xd". +export function formatTermLeft(blocksLeft: number): string { + if (blocksLeft <= 0) return 'Expired' + const minutes = blocksLeft * MINUTES_PER_BLOCK + if (minutes < MINUTES_PER_HOUR) return `~${minutes}m` + if (minutes < MINUTES_PER_DAY) return `~${Math.round(minutes / MINUTES_PER_HOUR)}h` + return `>${Math.floor(minutes / MINUTES_PER_DAY)}d` +} + +// TODO: real LBTC/USDT → USD once a price oracle exists. +export const USD_PLACEHOLDER = '$0.00 USD' + +export function truncateAddress(address: string): string { + if (address.length <= 10) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} diff --git a/web-v2/src/utils/lending.ts b/web-v2/src/utils/lending.ts new file mode 100644 index 0000000..c2854cc --- /dev/null +++ b/web-v2/src/utils/lending.ts @@ -0,0 +1,9 @@ +// Interest in satoshis. bps = basis points (1000 = 10%, 10000 = 100%). +export function calcInterest(principal: bigint, bps: number): bigint { + return (principal * BigInt(Math.round(bps))) / 10_000n +} + +// Display basis points as human-readable percent string: 1000 → "10.00%" +export function bpsToPercent(bps: number): string { + return `${(bps / 100).toFixed(2)}%` +} From 123a5f18a7b68476918feb20f03e5bd2724d92f0 Mon Sep 17 00:00:00 2001 From: Nikita Khromov Date: Fri, 5 Jun 2026 18:04:26 +0300 Subject: [PATCH 2/4] Added demo page and removed mocked data --- web-v2/src/App.tsx | 5 + web-v2/src/api/indexer/hooks.ts | 13 +- web-v2/src/components/AppLayout.tsx | 1 + web-v2/src/constants/routes.ts | 1 + web-v2/src/mocks/offers.ts | 208 ------------------ web-v2/src/pages/Dashboard/OffersTable.tsx | 21 +- web-v2/src/pages/Dashboard/useDashboard.ts | 6 +- .../CreateBorrowerAccountDemo.tsx | 0 .../Demos => Demo}/ScriptAuthCovenantDemo.tsx | 2 +- .../{Dashboard/Demos => Demo}/TxResult.tsx | 0 .../{Dashboard/Demos => Demo}/WalletDemo.tsx | 0 .../{Dashboard/Demos => Demo}/helpers.ts | 20 ++ web-v2/src/pages/Demo/index.tsx | 14 ++ 13 files changed, 63 insertions(+), 228 deletions(-) delete mode 100644 web-v2/src/mocks/offers.ts rename web-v2/src/pages/{Dashboard/Demos => Demo}/CreateBorrowerAccountDemo.tsx (100%) rename web-v2/src/pages/{Dashboard/Demos => Demo}/ScriptAuthCovenantDemo.tsx (99%) rename web-v2/src/pages/{Dashboard/Demos => Demo}/TxResult.tsx (100%) rename web-v2/src/pages/{Dashboard/Demos => Demo}/WalletDemo.tsx (100%) rename web-v2/src/pages/{Dashboard/Demos => Demo}/helpers.ts (84%) create mode 100644 web-v2/src/pages/Demo/index.tsx diff --git a/web-v2/src/App.tsx b/web-v2/src/App.tsx index ceb4de6..6be5c76 100644 --- a/web-v2/src/App.tsx +++ b/web-v2/src/App.tsx @@ -8,6 +8,7 @@ import { AppProviders } from '@/providers/AppProviders' import ErrorBoundary from './components/ErrorBoundary' import BorrowPage from './pages/Borrow' import DashboardPage from './pages/Dashboard' +import DemoPage from './pages/Demo' import DesignSystemPage from './pages/DesignSystem' import SupplyPage from './pages/Supply' import WalletDemoPage from './pages/WalletDemo' @@ -40,6 +41,10 @@ const router = createBrowserRouter([ path: RoutePath.WalletDemo, element: , }, + { + path: RoutePath.Demo, + element: , + }, ] : []), ], diff --git a/web-v2/src/api/indexer/hooks.ts b/web-v2/src/api/indexer/hooks.ts index 628094f..99f2c9d 100644 --- a/web-v2/src/api/indexer/hooks.ts +++ b/web-v2/src/api/indexer/hooks.ts @@ -1,4 +1,9 @@ -import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { + useQuery, + type QueryKey, + type UseQueryResult, + type UseQueryOptions, +} from '@tanstack/react-query' import { GC_TIME_MS, STALE_TIME_MS } from '../staleTime' import { @@ -16,13 +21,17 @@ import type { OfferDetails, OfferParticipant, OfferShort, OfferUtxo } from './sc export function useOffers( params: ListOffersParams = {}, - options: { refetchInterval?: number } = {}, + options: { + refetchInterval?: number + placeholderData?: UseQueryOptions['placeholderData'] + } = {}, ): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.list(params), queryFn: ({ signal }) => fetchOffers(params, { signal }), staleTime: STALE_TIME_MS.medium, refetchInterval: options.refetchInterval, + placeholderData: options.placeholderData, }) } diff --git a/web-v2/src/components/AppLayout.tsx b/web-v2/src/components/AppLayout.tsx index c71667a..e9abf07 100644 --- a/web-v2/src/components/AppLayout.tsx +++ b/web-v2/src/components/AppLayout.tsx @@ -18,6 +18,7 @@ const NAV = [ ? [ { to: RoutePath.DesignSystem, label: 'System' }, { to: RoutePath.WalletDemo, label: 'Wallet Demo' }, + { to: RoutePath.Demo, label: 'Demo' }, ] : []), ] diff --git a/web-v2/src/constants/routes.ts b/web-v2/src/constants/routes.ts index 4df8f69..fb666b7 100644 --- a/web-v2/src/constants/routes.ts +++ b/web-v2/src/constants/routes.ts @@ -4,6 +4,7 @@ export const RoutePath = { Supply: '/supply', DesignSystem: '/design-system', WalletDemo: '/wallet-demo', + Demo: '/demo', } as const export type RoutePath = (typeof RoutePath)[keyof typeof RoutePath] diff --git a/web-v2/src/mocks/offers.ts b/web-v2/src/mocks/offers.ts deleted file mode 100644 index 997ff28..0000000 --- a/web-v2/src/mocks/offers.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { OfferShort } from '@/api/indexer/schemas' - -export const MOCK_OFFERS: OfferShort[] = [ - { - id: '6adfbb12-6498-439d-bc49-4cdf4e27be9b', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 100, - loan_expiration_time: 2452825, - created_at_height: 2451829, - created_at_txid: 'ac5bebd1399cb885f9ffb9a3089373a53dfcacb6f6a9ce851ff755cae095d60d', - }, - { - id: 'bba212f1-81ce-4a0b-a1cf-3f0569c14580', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 1200, - loan_expiration_time: 2431851, - created_at_height: 2431756, - created_at_txid: '94ee9be637e1de238318486afb6ecbc62a14efa961fd28bd8f4599d8ea79281c', - }, - { - id: '4bf5995a-986e-4282-a34b-922142af5446', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 300, - loan_expiration_time: 2430181, - created_at_height: 2430085, - created_at_txid: '0ca30bb5c3e2270ab075a23332d6ab7cb82cbfc5572d95b1c431c497bf76a933', - }, - { - id: '821a9026-1d65-44e9-986f-92176e306f56', - status: 'cancelled', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 1500n, - principal_amount: 1000n, - interest_rate: 300, - loan_expiration_time: 2430110, - created_at_height: 2429979, - created_at_txid: '0ed73b70aad6d8c65ac578cefd40060d14205c23d3ca013553f1a337c60b69a4', - }, - { - id: '3a5dccd1-2a02-4977-a94c-d851d7867349', - status: 'cancelled', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 100, - loan_expiration_time: 2427291, - created_at_height: 2427262, - created_at_txid: '4a100b591e76b0f296750e99cee0f517adc98f01d045d29e67e4ecefd9597bfd', - }, - { - id: '274857d4-fe02-4ed4-9433-4a652b95fe71', - status: 'cancelled', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 100, - loan_expiration_time: 2427308, - created_at_height: 2427212, - created_at_txid: '9921fdea4cdf37f6d53d9504811dbf3211ac5837c4acfc3f1e18979e7cc08941', - }, - { - id: '75191635-b084-410e-993e-506320c37e95', - status: 'cancelled', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 2000n, - principal_amount: 1000n, - interest_rate: 100, - loan_expiration_time: 2427200, - created_at_height: 2427201, - created_at_txid: '2f150a6816204489a6b09810e7757a9a30185795b337cfae0eb19738c002e241', - }, - { - id: '305041d3-997c-44a2-8df5-cdc8c7225d2b', - status: 'liquidated', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 5000n, - principal_amount: 2000n, - interest_rate: 900, - loan_expiration_time: 2405715, - created_at_height: 2427169, - created_at_txid: 'cd6bb5f5264b569315a39fdbd2adad45c0486fbe41958501adb6053fad0cc3c4', - }, - { - id: 'c56c168d-2685-4c0f-a959-6322d1b4e5dc', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 1000n, - principal_amount: 300n, - interest_rate: 100, - loan_expiration_time: 2416905, - created_at_height: 2416816, - created_at_txid: '0485bef0d6ab612aa03d9493719dbe047ec9c74c4bb06cd3283c8cada8e81496', - }, - { - id: 'ae8d0045-07ac-4070-9dd1-052efecd846f', - status: 'active', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 300n, - principal_amount: 800n, - interest_rate: 1000, - loan_expiration_time: 2401660, - created_at_height: 2401487, - created_at_txid: '111361b57c00aca4aef871da3c9ef6020433aec043410813bb8bcdcbefadcd06', - }, - { - id: 'cc2d16a5-2ee0-4913-a505-1df9429cacf8', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 300n, - principal_amount: 800n, - interest_rate: 1000, - loan_expiration_time: 2398138, - created_at_height: 2397142, - created_at_txid: 'c6216de3d5e604b74283f16071207db44c054fd2d586b7d37ca54e924888e6d2', - }, - { - id: 'c9714216-eaff-423f-a4ed-09c0187389d2', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 200n, - principal_amount: 800n, - interest_rate: 500, - loan_expiration_time: 2398725, - created_at_height: 2396730, - created_at_txid: 'bf1c2cd07c2e33baaa8b19ed5e6f9542ae6598b9bbaaf0d4d5bb7ac42b07b52b', - }, - { - id: 'a2c7c4c5-6ac8-4aa2-a8ad-da969e111c24', - status: 'pending', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 30000n, - principal_amount: 5000n, - interest_rate: 1000, - loan_expiration_time: 2397673, - created_at_height: 2395676, - created_at_txid: 'd726ff4c404e3953c0062876e45f9c3d4210021ecdda519701c7e3bb64706fe5', - }, - { - id: '24028477-1503-4278-a829-8f2fc6f6db38', - status: 'active', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 1000n, - principal_amount: 100n, - interest_rate: 1500, - loan_expiration_time: 2381994, - created_at_height: 2380998, - created_at_txid: '4440d0c4f7d4107775b38a6d00a7e482ff162386da9f0fcb9827cd6bb901904b', - }, - { - id: 'e0aa61a4-d77d-448c-ab21-f7f2c9b2a297', - status: 'liquidated', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 10000n, - principal_amount: 500n, - interest_rate: 300, - loan_expiration_time: 2380947, - created_at_height: 2380751, - created_at_txid: 'f01c57b8cd9192a68a5239622d3581c30a282960e664a54445c04d229c7f495a', - }, - { - id: '7cc0ecc1-1f95-4484-89cd-1c8aa2cecf4e', - status: 'claimed', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5', - collateral_amount: 1000n, - principal_amount: 300n, - interest_rate: 100, - loan_expiration_time: 2389644, - created_at_height: 2379675, - created_at_txid: 'b6631a8dfc293e97affcdf68428695927cde5f46d73d957e4fede0bc2ed1f7c5', - }, - { - id: 'df05d20f-2e5b-4c80-ad97-315b791b6670', - status: 'pending', - collateral_asset: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', - principal_asset: '5add42b2dfeea8fe664bad6073e320518bb2be5c88357973091e9e278ce7084c', - collateral_amount: 500n, - principal_amount: 3500n, - interest_rate: 500, - loan_expiration_time: 2387644, - created_at_height: 2378647, - created_at_txid: 'c5e3777e0eb669a058607060c9f5b6966a30e14844c6729e2ecc0c019bc05ebf', - }, -] diff --git a/web-v2/src/pages/Dashboard/OffersTable.tsx b/web-v2/src/pages/Dashboard/OffersTable.tsx index 508099c..75b79a6 100644 --- a/web-v2/src/pages/Dashboard/OffersTable.tsx +++ b/web-v2/src/pages/Dashboard/OffersTable.tsx @@ -1,4 +1,5 @@ import { Skeleton, Table } from '@heroui/react' +import { keepPreviousData } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useBlockHeight } from '@/api/esplora/hooks' @@ -10,9 +11,7 @@ import { OfferStatusBadge } from '@/components/ui/OfferStatusBadge' import { UiButton } from '@/components/ui/UiButton' import { UiPagination } from '@/components/ui/UiPagination' import { ASSET_DECIMALS } from '@/constants/assets' -import { env } from '@/constants/env' import { DASHBOARD_REFETCH_INTERVAL_MS, TABLE_PAGE_SIZE } from '@/constants/lending' -import { MOCK_OFFERS } from '@/mocks/offers' import { formatAsset, formatTermLeft } from '@/utils/format' import { bpsToPercent, calcInterest } from '@/utils/lending' @@ -68,16 +67,12 @@ export function OffersTable() { const offersQuery = useOffers( { limit: FETCH_LIMIT, offset }, - { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS }, + { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, placeholderData: keepPreviousData }, ) const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) const currentBlockHeight = blockHeightQuery.data ?? 0 - // In DEV, slice mock data to simulate server-side pagination - const rawBatch: OfferShort[] = useMemo(() => { - if (env.DEV) return MOCK_OFFERS.slice(offset, offset + FETCH_LIMIT) - return offersQuery.data ?? [] - }, [offersQuery.data, offset]) + const rawBatch: OfferShort[] = offersQuery.data ?? [] const hasNextPage = rawBatch.length > TABLE_PAGE_SIZE const pageOffers = rawBatch.slice(0, TABLE_PAGE_SIZE) @@ -106,8 +101,9 @@ export function OffersTable() { }) }, [displayOffers, sort]) - const isLoading = (env.DEV ? false : offersQuery.isLoading) || blockHeightQuery.isLoading - const error = env.DEV ? null : (offersQuery.error as Error | null) + const isLoading = offersQuery.isLoading || blockHeightQuery.isLoading + const isFetching = offersQuery.isFetching || blockHeightQuery.isFetching + const error = offersQuery.error as Error | null const handleRetry = () => { void offersQuery.refetch() void blockHeightQuery.refetch() @@ -120,9 +116,10 @@ export function OffersTable() { type='button' aria-label='Refresh offers' onClick={handleRetry} - className='text-muted hover:text-foreground' + className='text-muted hover:text-foreground disabled:opacity-60' + disabled={isFetching} > - +

Most recent Borrow Offers

diff --git a/web-v2/src/pages/Dashboard/useDashboard.ts b/web-v2/src/pages/Dashboard/useDashboard.ts index 5899b54..1452368 100644 --- a/web-v2/src/pages/Dashboard/useDashboard.ts +++ b/web-v2/src/pages/Dashboard/useDashboard.ts @@ -7,7 +7,6 @@ import type { DisplayStatus } from '@/components/ui/OfferStatusBadge' import { ASSET_ID } from '@/constants/assets' import { env } from '@/constants/env' import { DASHBOARD_REFETCH_INTERVAL_MS, REPAYMENT_DUE_THRESHOLD_BLOCKS } from '@/constants/lending' -import { MOCK_OFFERS } from '@/mocks/offers' import { useWallet } from '@/providers/wallet/useWallet' import { getAssetBalance } from '@/utils/balance' import { calcInterest } from '@/utils/lending' @@ -70,10 +69,7 @@ export function useDashboard() { const currentBlockHeight = blockHeightQuery.data ?? 0 - const allOffers = useMemo( - () => (env.DEV ? MOCK_OFFERS : (offersQuery.data ?? [])), - [offersQuery.data], - ) + const allOffers = useMemo(() => offersQuery.data ?? [], [offersQuery.data]) const displayOffers = useMemo( () => diff --git a/web-v2/src/pages/Dashboard/Demos/CreateBorrowerAccountDemo.tsx b/web-v2/src/pages/Demo/CreateBorrowerAccountDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/CreateBorrowerAccountDemo.tsx rename to web-v2/src/pages/Demo/CreateBorrowerAccountDemo.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx b/web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx similarity index 99% rename from web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx rename to web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx index 3fccd29..efc2342 100644 --- a/web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx +++ b/web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx @@ -22,7 +22,7 @@ import { saveScriptAuthState, selectDemoScriptAuthInputs, useTxConfirmations, -} from '@/pages/Dashboard/Demos/helpers' +} from '@/pages/Demo/helpers' import { useLwk } from '@/providers/lwk/useLwk' import { useWallet } from '@/providers/wallet/useWallet' import { buildScriptAuthWitness, loadScriptAuthProgram } from '@/simplicity/script-auth/program' diff --git a/web-v2/src/pages/Dashboard/Demos/TxResult.tsx b/web-v2/src/pages/Demo/TxResult.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/TxResult.tsx rename to web-v2/src/pages/Demo/TxResult.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/WalletDemo.tsx b/web-v2/src/pages/Demo/WalletDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/WalletDemo.tsx rename to web-v2/src/pages/Demo/WalletDemo.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/helpers.ts b/web-v2/src/pages/Demo/helpers.ts similarity index 84% rename from web-v2/src/pages/Dashboard/Demos/helpers.ts rename to web-v2/src/pages/Demo/helpers.ts index 39388e8..d729979 100644 --- a/web-v2/src/pages/Dashboard/Demos/helpers.ts +++ b/web-v2/src/pages/Demo/helpers.ts @@ -53,6 +53,26 @@ export interface SavedScriptAuthState { fundingTxid: string } +const SCRIPT_AUTH_STATE_KEY = 'demo:scriptAuthState' + +export function saveScriptAuthState(state: SavedScriptAuthState): void { + try { + localStorage.setItem(SCRIPT_AUTH_STATE_KEY, JSON.stringify(state)) + } catch (err) { + console.warn(err) + } +} + +export function latestScriptAuthState(): SavedScriptAuthState | null { + try { + const raw = localStorage.getItem(SCRIPT_AUTH_STATE_KEY) + return raw ? (JSON.parse(raw) as SavedScriptAuthState) : null + } catch (err) { + console.warn(err) + return null + } +} + export function useTxConfirmations(txid: string | null): number | null { const [confirmedTx, setConfirmedTx] = useState<{ confirmations: number diff --git a/web-v2/src/pages/Demo/index.tsx b/web-v2/src/pages/Demo/index.tsx new file mode 100644 index 0000000..4fa7820 --- /dev/null +++ b/web-v2/src/pages/Demo/index.tsx @@ -0,0 +1,14 @@ +import CreateBorrowerAccountDemo from './CreateBorrowerAccountDemo' +import ScriptAuthCovenantDemo from './ScriptAuthCovenantDemo' +import { WalletDemo } from './WalletDemo' + +export default function DemoPage() { + return ( +
+

Demo

+ + + +
+ ) +} From a14e3296967cb1ea4113eaa62d6507a2a3f4aa44 Mon Sep 17 00:00:00 2001 From: Nikita Khromov Date: Mon, 8 Jun 2026 00:52:12 +0300 Subject: [PATCH 3/4] Added new filters --- web-v2/src/App.tsx | 5 - web-v2/src/api/indexer/hooks.ts | 40 ++- web-v2/src/api/indexer/methods.ts | 8 + web-v2/src/api/indexer/queryKeys.ts | 5 +- web-v2/src/components/AppLayout.tsx | 1 - .../src/components/icons/ChevronDownIcon.tsx | 23 ++ web-v2/src/components/ui/UiPagination.tsx | 3 +- web-v2/src/constants/routes.ts | 1 - web-v2/src/pages/Dashboard/BorrowCard.tsx | 3 +- web-v2/src/pages/Dashboard/OffersTable.tsx | 77 +++--- web-v2/src/pages/Dashboard/OverviewStats.tsx | 5 +- web-v2/src/pages/Dashboard/SupplyCard.tsx | 3 +- web-v2/src/pages/Dashboard/useDashboard.ts | 108 +++++---- web-v2/src/pages/WalletDemo/WalletDemo.tsx | 229 ------------------ web-v2/src/pages/WalletDemo/index.tsx | 11 - web-v2/src/utils/format.ts | 4 +- web-v2/src/utils/lending.ts | 22 ++ 17 files changed, 188 insertions(+), 360 deletions(-) create mode 100644 web-v2/src/components/icons/ChevronDownIcon.tsx delete mode 100644 web-v2/src/pages/WalletDemo/WalletDemo.tsx delete mode 100644 web-v2/src/pages/WalletDemo/index.tsx diff --git a/web-v2/src/App.tsx b/web-v2/src/App.tsx index 6be5c76..315026a 100644 --- a/web-v2/src/App.tsx +++ b/web-v2/src/App.tsx @@ -11,7 +11,6 @@ import DashboardPage from './pages/Dashboard' import DemoPage from './pages/Demo' import DesignSystemPage from './pages/DesignSystem' import SupplyPage from './pages/Supply' -import WalletDemoPage from './pages/WalletDemo' const router = createBrowserRouter([ { @@ -37,10 +36,6 @@ const router = createBrowserRouter([ path: RoutePath.DesignSystem, element: , }, - { - path: RoutePath.WalletDemo, - element: , - }, { path: RoutePath.Demo, element: , diff --git a/web-v2/src/api/indexer/hooks.ts b/web-v2/src/api/indexer/hooks.ts index 99f2c9d..9a5a1cd 100644 --- a/web-v2/src/api/indexer/hooks.ts +++ b/web-v2/src/api/indexer/hooks.ts @@ -1,8 +1,8 @@ import { - useQuery, type QueryKey, - type UseQueryResult, + useQuery, type UseQueryOptions, + type UseQueryResult, } from '@tanstack/react-query' import { GC_TIME_MS, STALE_TIME_MS } from '../staleTime' @@ -13,6 +13,7 @@ import { fetchOfferParticipants, fetchOfferParticipantsHistory, fetchOffers, + fetchOffersBatch, fetchOfferUtxos, type ListOffersParams, } from './methods' @@ -23,7 +24,12 @@ export function useOffers( params: ListOffersParams = {}, options: { refetchInterval?: number - placeholderData?: UseQueryOptions['placeholderData'] + placeholderData?: UseQueryOptions< + OfferShort[], + Error, + OfferShort[], + QueryKey + >['placeholderData'] } = {}, ): UseQueryResult { return useQuery({ @@ -35,6 +41,22 @@ export function useOffers( }) } +// Fetch offers by exact id list (POST /offers/batch). Use for the user's own +// offers, where the id set is known and must be resolved fully — unlike the +// paginated `useOffers`, which only returns one page. +export function useOffersBatch( + ids: string[], + options: { refetchInterval?: number } = {}, +): UseQueryResult { + return useQuery({ + queryKey: offersQueryKeys.batch(ids), + queryFn: ({ signal }) => fetchOffersBatch(ids, { signal }), + staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, + enabled: ids.length > 0, + }) +} + export function useOffer(offerId: string): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.detail(offerId), @@ -72,20 +94,28 @@ export function useOfferParticipantsHistory(offerId: string): UseQueryResult { +export function useOfferIdsByScript( + scriptPubkeyHex: string, + options: { refetchInterval?: number } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.byScript(scriptPubkeyHex), queryFn: ({ signal }) => fetchOfferIdsByScript(scriptPubkeyHex, { signal }), staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, enabled: !!scriptPubkeyHex, }) } -export function useOfferIdsByBorrowerPubkey(borrowerPubkeyHex: string): UseQueryResult { +export function useOfferIdsByBorrowerPubkey( + borrowerPubkeyHex: string, + options: { refetchInterval?: number } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.byBorrower(borrowerPubkeyHex), queryFn: ({ signal }) => fetchOfferIdsByBorrowerPubkey(borrowerPubkeyHex, { signal }), staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, enabled: !!borrowerPubkeyHex, }) } diff --git a/web-v2/src/api/indexer/methods.ts b/web-v2/src/api/indexer/methods.ts index 4200511..be5a992 100644 --- a/web-v2/src/api/indexer/methods.ts +++ b/web-v2/src/api/indexer/methods.ts @@ -41,11 +41,17 @@ function postBatch( }) } +export type SortDir = 'asc' | 'desc' + export interface ListOffersParams { status?: OfferStatus asset?: string limit?: number offset?: number + // Server-side sort. NOTE: backend does not honor these yet — sent as a + // forward-compatible convention; results are currently returned unsorted. + sortBy?: string + sortDir?: SortDir } function toQueryParams(params: ListOffersParams): Record { @@ -54,6 +60,8 @@ function toQueryParams(params: ListOffersParams): Record { if (params.asset) queryParams.asset = params.asset if (params.limit !== undefined) queryParams.limit = String(params.limit) if (params.offset !== undefined) queryParams.offset = String(params.offset) + if (params.sortBy) queryParams.sort_by = params.sortBy + if (params.sortDir) queryParams.sort_dir = params.sortDir return queryParams } diff --git a/web-v2/src/api/indexer/queryKeys.ts b/web-v2/src/api/indexer/queryKeys.ts index ed6f083..a4148c5 100644 --- a/web-v2/src/api/indexer/queryKeys.ts +++ b/web-v2/src/api/indexer/queryKeys.ts @@ -4,9 +4,10 @@ import type { ListOffersParams } from './methods' export const offersQueryKeys = { all: ['offers'] as const, - list: ({ status, asset, limit, offset }: ListOffersParams) => - ['offers', 'list', status, asset, limit, offset] as const, + list: ({ status, asset, limit, offset, sortBy, sortDir }: ListOffersParams) => + ['offers', 'list', status, asset, limit, offset, sortBy, sortDir] as const, detail: (offerId: string) => ['offers', 'detail', offerId] as const, + batch: (ids: string[]) => ['offers', 'batch', [...ids].sort()] as const, utxos: (offerId: string) => ['offers', 'utxos', offerId] as const, participants: (offerId: string) => ['offers', 'participants', offerId] as const, participantsHistory: (offerId: string) => ['offers', 'participants-history', offerId] as const, diff --git a/web-v2/src/components/AppLayout.tsx b/web-v2/src/components/AppLayout.tsx index e9abf07..e3be142 100644 --- a/web-v2/src/components/AppLayout.tsx +++ b/web-v2/src/components/AppLayout.tsx @@ -17,7 +17,6 @@ const NAV = [ ...(env.DEV ? [ { to: RoutePath.DesignSystem, label: 'System' }, - { to: RoutePath.WalletDemo, label: 'Wallet Demo' }, { to: RoutePath.Demo, label: 'Demo' }, ] : []), diff --git a/web-v2/src/components/icons/ChevronDownIcon.tsx b/web-v2/src/components/icons/ChevronDownIcon.tsx new file mode 100644 index 0000000..651fef5 --- /dev/null +++ b/web-v2/src/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ChevronDownIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/ui/UiPagination.tsx b/web-v2/src/components/ui/UiPagination.tsx index 44dd206..8a245a9 100644 --- a/web-v2/src/components/ui/UiPagination.tsx +++ b/web-v2/src/components/ui/UiPagination.tsx @@ -13,9 +13,8 @@ interface UiPaginationProps { currentPage: number onPageChange: (page: number) => void summary?: ReactNode - // Known total — enables page number list + // pageCount = numbered pages; hasNextPage = prev/next only (no total count) pageCount?: number - // Unknown total (server-side without count) — only prev/next hasNextPage?: boolean } diff --git a/web-v2/src/constants/routes.ts b/web-v2/src/constants/routes.ts index fb666b7..644c03c 100644 --- a/web-v2/src/constants/routes.ts +++ b/web-v2/src/constants/routes.ts @@ -3,7 +3,6 @@ export const RoutePath = { Borrow: '/borrow', Supply: '/supply', DesignSystem: '/design-system', - WalletDemo: '/wallet-demo', Demo: '/demo', } as const diff --git a/web-v2/src/pages/Dashboard/BorrowCard.tsx b/web-v2/src/pages/Dashboard/BorrowCard.tsx index 32c9d45..4e5a4b3 100644 --- a/web-v2/src/pages/Dashboard/BorrowCard.tsx +++ b/web-v2/src/pages/Dashboard/BorrowCard.tsx @@ -5,7 +5,7 @@ import { UiButton } from '@/components/ui/UiButton' import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' import { ASSET_DECIMALS } from '@/constants/assets' import { RoutePath } from '@/constants/routes' -import { formatAsset, truncateAddress, USD_PLACEHOLDER } from '@/utils/format' +import { formatAsset, truncateAddress } from '@/utils/format' import { BalanceCard } from './BalanceCard' import { AssetAmount, CardAlert } from './BaseCard' @@ -35,7 +35,6 @@ export function BorrowCard({ data, isLoading, isReady, onRetry }: BorrowCardProp errorMessage='Failed to load your borrows.' onRetry={onRetry} balance={} - fiat={USD_PLACEHOLDER} > = { + collateral_amount: 'collateral_amount', + principal_amount: 'principal_amount', + earn: 'earn', + interest_rate: 'interest_rate', + termLeft: 'loan_expiration_time', } function SortableHeader({ @@ -52,9 +47,13 @@ function SortableHeader({ className='hover:text-foreground inline-flex items-center gap-1' > {label} - + {active ? ( + + ) : ( + + )} ) } @@ -66,41 +65,36 @@ export function OffersTable() { const offset = (page - 1) * TABLE_PAGE_SIZE const offersQuery = useOffers( - { limit: FETCH_LIMIT, offset }, + { + limit: TABLE_PAGE_SIZE + 1, + offset, + sortBy: sort ? SORT_FIELD[sort.col] : undefined, + sortDir: sort?.dir, + }, { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, placeholderData: keepPreviousData }, ) const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) const currentBlockHeight = blockHeightQuery.data ?? 0 const rawBatch: OfferShort[] = offersQuery.data ?? [] - const hasNextPage = rawBatch.length > TABLE_PAGE_SIZE - const pageOffers = rawBatch.slice(0, TABLE_PAGE_SIZE) const displayOffers = useMemo( - () => pageOffers.map(o => toDisplayOffer(o, currentBlockHeight)), - [pageOffers, currentBlockHeight], + () => + (offersQuery.data ?? []) + .slice(0, TABLE_PAGE_SIZE) + .map(o => toDisplayOffer(o, currentBlockHeight)), + [offersQuery.data, currentBlockHeight], ) + // Sort is server-side (refetches from offset 0) — reset to page 1. const handleSort = (col: SortCol) => { setSort(prev => prev?.col === col ? { col, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' }, ) + setPage(1) } - // Sort applies to current page only (server-side sort not yet supported by API) - const sorted = useMemo(() => { - if (!sort) return displayOffers - const dir = sort.dir === 'asc' ? 1 : -1 - return [...displayOffers].sort((a, b) => { - const av = a[sort.col] - const bv = b[sort.col] - if (av < bv) return -dir - if (av > bv) return dir - return 0 - }) - }, [displayOffers, sort]) - const isLoading = offersQuery.isLoading || blockHeightQuery.isLoading const isFetching = offersQuery.isFetching || blockHeightQuery.isFetching const error = offersQuery.error as Error | null @@ -137,7 +131,7 @@ export function OffersTable() { Retry
- ) : sorted.length === 0 ? ( + ) : displayOffers.length === 0 ? (

No offers found

) : ( @@ -181,7 +175,7 @@ export function OffersTable() { Status - + {offer => ( @@ -202,14 +196,7 @@ export function OffersTable() { - { - setPage(p) - setSort(null) - }} - /> +
)} diff --git a/web-v2/src/pages/Dashboard/OverviewStats.tsx b/web-v2/src/pages/Dashboard/OverviewStats.tsx index 5c7865b..6ba484d 100644 --- a/web-v2/src/pages/Dashboard/OverviewStats.tsx +++ b/web-v2/src/pages/Dashboard/OverviewStats.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@heroui/react' import { useMemo } from 'react' import { ASSET_DECIMALS } from '@/constants/assets' -import { formatAsset, USD_PLACEHOLDER } from '@/utils/format' +import { formatAsset } from '@/utils/format' import { bpsToPercent } from '@/utils/lending' import { AssetAmount } from './BaseCard' @@ -17,7 +17,6 @@ interface OverviewStat { label: string value: string unit?: string - // No price oracle yet, so monetary tiles fall back to USD_PLACEHOLDER. fiat?: string } @@ -46,13 +45,11 @@ export function OverviewStats({ data, isLoading }: OverviewStatsProps) { label: 'Collateral Locked', value: formatAsset(data.totalCollateral, ASSET_DECIMALS.LBTC), unit: 'LBTC', - fiat: USD_PLACEHOLDER, }, { label: 'Borrowings', value: formatAsset(data.totalBorrowings, ASSET_DECIMALS.USDT), unit: 'USDT', - fiat: USD_PLACEHOLDER, }, { label: 'Average APR', value: bpsToPercent(data.avgApr) }, { label: 'Active Loans', value: String(data.activeLoans) }, diff --git a/web-v2/src/pages/Dashboard/SupplyCard.tsx b/web-v2/src/pages/Dashboard/SupplyCard.tsx index ff99edc..739ce5f 100644 --- a/web-v2/src/pages/Dashboard/SupplyCard.tsx +++ b/web-v2/src/pages/Dashboard/SupplyCard.tsx @@ -5,7 +5,7 @@ import { UiButton } from '@/components/ui/UiButton' import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' import { ASSET_DECIMALS } from '@/constants/assets' import { RoutePath } from '@/constants/routes' -import { formatAsset, truncateAddress, USD_PLACEHOLDER } from '@/utils/format' +import { formatAsset, truncateAddress } from '@/utils/format' import { BalanceCard } from './BalanceCard' import { AssetAmount, CardAlert } from './BaseCard' @@ -35,7 +35,6 @@ export function SupplyCard({ data, isLoading, isReady, onRetry }: SupplyCardProp errorMessage='Failed to load your supply.' onRetry={onRetry} balance={} - fiat={USD_PLACEHOLDER} > offersQuery.data ?? [], [offersQuery.data]) - + // Overview is computed from the first offers page (see FIXME below). const displayOffers = useMemo( - () => - allOffers.map(offer => { - const termLeft = offer.loan_expiration_time - currentBlockHeight - const displayStatus = offer.status === 'pending' && termLeft <= 0 ? 'expired' : offer.status - return { - ...offer, - termLeft, - displayStatus, - earn: calcInterest(offer.principal_amount, offer.interest_rate), - } - }), - [allOffers, currentBlockHeight], + () => (offersQuery.data ?? []).map(offer => toDisplayOffer(offer, currentBlockHeight)), + [offersQuery.data, currentBlockHeight], ) - const offerById = useMemo(() => { - const map = new Map() - for (const offer of displayOffers) map.set(offer.id, offer) - return map - }, [displayOffers]) + // The user's own offers are resolved by exact id (batch), not joined against + // the offers page — otherwise any offer outside page 1 would be silently dropped. + const borrowerOffersQuery = useOffersBatch(borrowerIdsQuery.data ?? [], poll) + const supplyOffersQuery = useOffersBatch(supplyIdsQuery.data ?? [], poll) + + const borrowerOffers = useMemo( + () => (borrowerOffersQuery.data ?? []).map(o => toDisplayOffer(o, currentBlockHeight)), + [borrowerOffersQuery.data, currentBlockHeight], + ) + + const supplyOffers = useMemo( + () => (supplyOffersQuery.data ?? []).map(o => toDisplayOffer(o, currentBlockHeight)), + [supplyOffersQuery.data, currentBlockHeight], + ) + // FIXME(backend): computed over one page (`useOffers({})`), not all offers — totals + // are approximate. Needs a server-side aggregate endpoint (GET /offers/stats). const overview = useMemo(() => { const active = displayOffers.filter(o => o.status === 'active') const totalCollateral = active.reduce((acc, o) => acc + o.collateral_amount, 0n) @@ -112,10 +113,8 @@ export function useDashboard() { const usdtBalance = getAssetBalance(balances, NETWORK_ASSETS.USDT) const borrows = useMemo(() => { - const ids = borrowerIdsQuery.data ?? [] - const mine = ids.map(id => offerById.get(id)).filter((o): o is DisplayOffer => !!o) - const active = mine.filter(o => o.status === 'active') - const pending = mine.filter(o => o.status === 'pending') + const active = borrowerOffers.filter(o => o.status === 'active') + const pending = borrowerOffers.filter(o => o.status === 'pending') const nearExpiryOffers = active.filter( o => o.termLeft > 0 && o.termLeft < REPAYMENT_DUE_THRESHOLD_BLOCKS, ) @@ -129,55 +128,66 @@ export function useDashboard() { toRepay: nearExpiryOffers.length, }, nearExpiryOffers, - isLoading: isReady && borrowerIdsQuery.isLoading, - error: borrowerIdsQuery.error, + isLoading: isReady && (borrowerIdsQuery.isLoading || borrowerOffersQuery.isLoading), + error: borrowerIdsQuery.error ?? borrowerOffersQuery.error, } }, [ isReady, lbtcBalance, - borrowerIdsQuery.data, + borrowerOffers, borrowerIdsQuery.isLoading, borrowerIdsQuery.error, - offerById, + borrowerOffersQuery.isLoading, + borrowerOffersQuery.error, ]) const supply = useMemo(() => { - const ids = supplyIdsQuery.data ?? [] - const mine = ids.map(id => offerById.get(id)).filter((o): o is DisplayOffer => !!o) - const active = mine.filter(o => o.status === 'active') - const claimableOffers = mine.filter(o => o.status === 'repaid') + const active = supplyOffers.filter(o => o.status === 'active') + const claimableOffers = supplyOffers.filter(o => o.status === 'repaid') return { balance: usdtBalance, stats: { - suppliedLoans: mine.reduce((acc, o) => acc + o.principal_amount, 0n), + suppliedLoans: supplyOffers.reduce((acc, o) => acc + o.principal_amount, 0n), interestOutstanding: active.reduce((acc, o) => acc + o.earn, 0n), activeLoans: active.length, repaidToClaim: claimableOffers.length, }, claimableOffers, - isLoading: isReady && supplyIdsQuery.isLoading, - error: supplyIdsQuery.error, + isLoading: isReady && (supplyIdsQuery.isLoading || supplyOffersQuery.isLoading), + error: supplyIdsQuery.error ?? supplyOffersQuery.error, } }, [ isReady, usdtBalance, - supplyIdsQuery.data, + supplyOffers, supplyIdsQuery.isLoading, supplyIdsQuery.error, - offerById, + supplyOffersQuery.isLoading, + supplyOffersQuery.error, ]) const offersRefetch = offersQuery.refetch const borrowerIdsRefetch = borrowerIdsQuery.refetch const supplyIdsRefetch = supplyIdsQuery.refetch + const borrowerOffersRefetch = borrowerOffersQuery.refetch + const supplyOffersRefetch = supplyOffersQuery.refetch const blockHeightRefetch = blockHeightQuery.refetch const refetch = useCallback(() => { void offersRefetch() void borrowerIdsRefetch() void supplyIdsRefetch() + void borrowerOffersRefetch() + void supplyOffersRefetch() void blockHeightRefetch() - }, [offersRefetch, borrowerIdsRefetch, supplyIdsRefetch, blockHeightRefetch]) + }, [ + offersRefetch, + borrowerIdsRefetch, + supplyIdsRefetch, + borrowerOffersRefetch, + supplyOffersRefetch, + blockHeightRefetch, + ]) return { overview, diff --git a/web-v2/src/pages/WalletDemo/WalletDemo.tsx b/web-v2/src/pages/WalletDemo/WalletDemo.tsx deleted file mode 100644 index 6a17749..0000000 --- a/web-v2/src/pages/WalletDemo/WalletDemo.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import type { XOnlyPublicKey } from 'lwk_web' -import { useEffect, useState } from 'react' - -import { env } from '@/constants/env' -import type { ConnectionStatus, WalletType } from '@/lib/wallet-core/types' -import { useLwk } from '@/providers/lwk/useLwk' -import { useWallet } from '@/providers/wallet/useWallet' - -type Phase = 'no-usb' | 'usb-detected' | 'connecting' | 'locked' | 'ready' - -function resolvePhase( - connectionStatus: ConnectionStatus, - usbDeviceDetected: boolean, - syncing: boolean, -): Phase { - if (connectionStatus === 'locked') return 'locked' - if (connectionStatus === 'ready') return 'ready' - if (syncing) return 'connecting' - return usbDeviceDetected ? 'usb-detected' : 'no-usb' -} - -export function WalletDemo() { - const { network, isTestnet, isMainnet, isRegtest } = useLwk() - const { - connectionStatus, - syncing, - isError, - error, - balances, - usbDeviceDetected, - connect, - getReceiveAddress, - verifyReceiveAddress, - getXOnlyPublicKey, - connectorId, - } = useWallet() - - const [walletType, setWalletType] = useState('Wpkh') - const [sendAddress, setSendAddress] = useState('') - const [sendAmount, setSendAmount] = useState('') - const [verifyingAddress, setVerifyingAddress] = useState(false) - const [xOnlyPubKey, setXOnlyPubKey] = useState(null) - const [receiveAddress, setReceiveAddress] = useState(null) - - useEffect(() => { - if (connectionStatus !== 'ready') return - let cancelled = false - getXOnlyPublicKey() - .then(key => { - if (!cancelled) setXOnlyPubKey(key) - }) - .catch(console.warn) - return () => { - cancelled = true - } - }, [connectionStatus, getXOnlyPublicKey]) - - useEffect(() => { - if (connectionStatus !== 'ready') return - let cancelled = false - getReceiveAddress() - .then(addr => { - if (!cancelled) setReceiveAddress(addr) - }) - .catch(console.warn) - return () => { - cancelled = true - } - }, [connectionStatus, getReceiveAddress]) - - const phase = resolvePhase(connectionStatus, usbDeviceDetected, syncing) - - const handleVerifyAddress = async () => { - setVerifyingAddress(true) - console.warn('[Dashboard] handleVerifyAddress: requesting address verification on device...') - try { - const addr = await verifyReceiveAddress() - console.warn('[Dashboard] handleVerifyAddress: device confirmed address →', addr) - } catch (err) { - console.warn('[Dashboard] handleVerifyAddress: error', err) - } finally { - setVerifyingAddress(false) - } - } - - return ( -
- {phase === 'no-usb' && ( -
-
- -
- {env.VITE_DEBUG_MNEMONIC && ( - <> - - - )} -
- )} - - {phase === 'usb-detected' && ( -
-
- Wallet type - - -
-
- -
-
- )} - - {phase === 'connecting' &&

Connecting to Jade...

} - - {phase === 'locked' && ( -
-

- Enter PIN on device - {connectorId && ({connectorId})} -

- {syncing &&

Loading wallet...

} -
- )} - - {phase === 'ready' && ( -
-
-

Receive address

- {receiveAddress} - -
- -
-

- Balances - {connectorId && ({connectorId})} -

- {syncing ? ( -

Syncing...

- ) : Object.entries(balances).length === 0 ? ( -

No balance

- ) : ( -
    - {Object.entries(balances).map(([assetId, amount]) => ( -
  • - {assetId}: {amount} -
  • - ))} -
- )} -
- - {env.VITE_DEBUG_MNEMONIC && xOnlyPubKey && ( -
-

X-Only Public Key (Simplicity)

- {xOnlyPubKey.toString()} -
- )} - -
-

Send Transfer

- setSendAddress(e.target.value)} - /> - setSendAmount(e.target.value)} - /> -
-
- )} - - {isError && error &&

{error}

} - -

- Network: {network} -

-

- isTestnet: {isTestnet.toString()} / isMainnet: {isMainnet.toString()} / isRegtest:{' '} - {isRegtest.toString()} -

-
- ) -} diff --git a/web-v2/src/pages/WalletDemo/index.tsx b/web-v2/src/pages/WalletDemo/index.tsx deleted file mode 100644 index f359abe..0000000 --- a/web-v2/src/pages/WalletDemo/index.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { WalletDemo } from './WalletDemo' - -// DEV-only page for manually testing wallet connect / sign / broadcast flows. -export default function WalletDemoPage() { - return ( -
-

Wallet Demo

- -
- ) -} diff --git a/web-v2/src/utils/format.ts b/web-v2/src/utils/format.ts index 5de381a..57949d7 100644 --- a/web-v2/src/utils/format.ts +++ b/web-v2/src/utils/format.ts @@ -26,8 +26,8 @@ export function formatTermLeft(blocksLeft: number): string { return `>${Math.floor(minutes / MINUTES_PER_DAY)}d` } -// TODO: real LBTC/USDT → USD once a price oracle exists. -export const USD_PLACEHOLDER = '$0.00 USD' +// TODO(oracle): add fiat conversion (LBTC/USDT → USD). `fiat` props left unset +// until then — a "$0.00" placeholder would read as a real zero balance. export function truncateAddress(address: string): string { if (address.length <= 10) return address diff --git a/web-v2/src/utils/lending.ts b/web-v2/src/utils/lending.ts index c2854cc..b58d81a 100644 --- a/web-v2/src/utils/lending.ts +++ b/web-v2/src/utils/lending.ts @@ -1,3 +1,6 @@ +import type { OfferShort } from '@/api/indexer/schemas' +import type { DisplayStatus } from '@/components/ui/OfferStatusBadge' + // Interest in satoshis. bps = basis points (1000 = 10%, 10000 = 100%). export function calcInterest(principal: bigint, bps: number): bigint { return (principal * BigInt(Math.round(bps))) / 10_000n @@ -7,3 +10,22 @@ export function calcInterest(principal: bigint, bps: number): bigint { export function bpsToPercent(bps: number): string { return `${(bps / 100).toFixed(2)}%` } + +// Offer enriched with values derived from the current chain tip, ready for display. +export interface DisplayOffer extends OfferShort { + termLeft: number + displayStatus: DisplayStatus + earn: bigint // total interest, precomputed for sorting +} + +// Single source of truth for offer → display mapping (used by dashboard + table). +export function toDisplayOffer(offer: OfferShort, currentBlockHeight: number): DisplayOffer { + const termLeft = offer.loan_expiration_time - currentBlockHeight + const displayStatus = offer.status === 'pending' && termLeft <= 0 ? 'expired' : offer.status + return { + ...offer, + termLeft, + displayStatus, + earn: calcInterest(offer.principal_amount, offer.interest_rate), + } +} From d63865400651a6dab7a1408ab05378e7424b77ac Mon Sep 17 00:00:00 2001 From: Nikita Khromov Date: Mon, 8 Jun 2026 13:00:53 +0300 Subject: [PATCH 4/4] fixed wallet connection --- web-v2/src/pages/Dashboard/BalanceCard.tsx | 10 +++++++++- web-v2/src/pages/Dashboard/BorrowCard.tsx | 2 ++ web-v2/src/pages/Dashboard/OffersTable.tsx | 2 +- web-v2/src/pages/Dashboard/useDashboard.ts | 7 ++++++- web-v2/src/providers/wallet/WalletProvider.tsx | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/web-v2/src/pages/Dashboard/BalanceCard.tsx b/web-v2/src/pages/Dashboard/BalanceCard.tsx index effdf46..fd7bfd5 100644 --- a/web-v2/src/pages/Dashboard/BalanceCard.tsx +++ b/web-v2/src/pages/Dashboard/BalanceCard.tsx @@ -16,6 +16,8 @@ export function BalanceCard({ onRetry, balance, fiat, + unsupported, + unsupportedMessage, children, }: { icon: ReactNode @@ -29,9 +31,11 @@ export function BalanceCard({ onRetry: () => void balance: ReactNode fiat?: string + unsupported?: boolean + unsupportedMessage?: string children: ReactNode }) { - const showBody = isReady && !error + const showBody = isReady && !error && !unsupported return ( {connectMessage}

+ ) : unsupported ? ( +
+

{unsupportedMessage}

+
) : error ? ( ) : ( diff --git a/web-v2/src/pages/Dashboard/BorrowCard.tsx b/web-v2/src/pages/Dashboard/BorrowCard.tsx index 4e5a4b3..b6d9c4f 100644 --- a/web-v2/src/pages/Dashboard/BorrowCard.tsx +++ b/web-v2/src/pages/Dashboard/BorrowCard.tsx @@ -33,6 +33,8 @@ export function BorrowCard({ data, isLoading, isReady, onRetry }: BorrowCardProp error={data.error} connectMessage='Connect your wallet to view your borrows.' errorMessage='Failed to load your borrows.' + unsupported={data.unsupported} + unsupportedMessage="This wallet can't expose a borrower key, so your borrows can't be shown." onRetry={onRetry} balance={} > diff --git a/web-v2/src/pages/Dashboard/OffersTable.tsx b/web-v2/src/pages/Dashboard/OffersTable.tsx index 7b89b98..62d182f 100644 --- a/web-v2/src/pages/Dashboard/OffersTable.tsx +++ b/web-v2/src/pages/Dashboard/OffersTable.tsx @@ -97,7 +97,7 @@ export function OffersTable() { const isLoading = offersQuery.isLoading || blockHeightQuery.isLoading const isFetching = offersQuery.isFetching || blockHeightQuery.isFetching - const error = offersQuery.error as Error | null + const error = (offersQuery.error ?? blockHeightQuery.error) as Error | null const handleRetry = () => { void offersQuery.refetch() void blockHeightQuery.refetch() diff --git a/web-v2/src/pages/Dashboard/useDashboard.ts b/web-v2/src/pages/Dashboard/useDashboard.ts index b1cda60..7dfa695 100644 --- a/web-v2/src/pages/Dashboard/useDashboard.ts +++ b/web-v2/src/pages/Dashboard/useDashboard.ts @@ -47,6 +47,8 @@ export interface DashboardBorrows { nearExpiryOffers: DisplayOffer[] isLoading: boolean error: Error | null + // Wallet can't expose an x-only pubkey → borrows can't be looked up (≠ "no borrows"). + unsupported: boolean } export interface DashboardSupply { @@ -130,9 +132,11 @@ export function useDashboard() { nearExpiryOffers, isLoading: isReady && (borrowerIdsQuery.isLoading || borrowerOffersQuery.isLoading), error: borrowerIdsQuery.error ?? borrowerOffersQuery.error, + unsupported: isReady && !xOnlyPubkey, } }, [ isReady, + xOnlyPubkey, lbtcBalance, borrowerOffers, borrowerIdsQuery.isLoading, @@ -194,7 +198,8 @@ export function useDashboard() { borrows, supply, isReady, - isLoading: isReady && (offersQuery.isLoading || blockHeightQuery.isLoading), + // Overview is public — must not gate loading on isReady (else flashes zeros). + isLoading: offersQuery.isLoading || blockHeightQuery.isLoading, refetch, } } diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index aac612e..ad06d57 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -184,7 +184,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const address = wollet.address().address() const receiveAddress = address.toString() const scriptPubkey = address.scriptPubkey().toString() - const xOnlyPubkey = (await connector.getXOnlyPublicKey?.()) ?? null + const xOnlyPubkey = (await connector.getXOnlyPublicKey?.())?.toString() ?? null setState(s => ({ ...s,