From c832b80a2fe22cb7ebfb48bcb89bef25ffb410c8 Mon Sep 17 00:00:00 2001 From: Parth <143504541+parrth20@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:34:39 +0530 Subject: [PATCH] feat(earn): show own offer status in local orderbook (#1420) --- src/components/earn/EarnPage.test.tsx | 84 +++++++++++++- src/components/earn/EarnPage.tsx | 38 +++++++ src/components/earn/OfferCard.test.tsx | 38 +++++++ src/components/earn/OfferCard.tsx | 95 +++++++++++++++- src/constants/jam.ts | 9 ++ src/i18n/locales/en/translation.json | 9 +- src/lib/api/orderbook.ts | 17 ++- src/stories/jam/OfferCard.stories.tsx | 151 +++++++++++++++++++++++++ 8 files changed, 426 insertions(+), 15 deletions(-) create mode 100644 src/stories/jam/OfferCard.stories.tsx diff --git a/src/components/earn/EarnPage.test.tsx b/src/components/earn/EarnPage.test.tsx index 2c4dcaa7..fe6c0954 100644 --- a/src/components/earn/EarnPage.test.tsx +++ b/src/components/earn/EarnPage.test.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as JAM from '@/constants/jam' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import { jmSessionStore } from '@/store/jmSessionStore' import type { EarnFormValues } from './EarnForm' @@ -10,6 +11,12 @@ import { EarnPage } from './EarnPage' const mocks = vi.hoisted(() => ({ developerMode: false, feeConfigMissing: false, + orderbookData: vi.fn<() => unknown>(), + orderbookQueryOptions: vi.fn(), + orderbookQueryState: { + isError: false, + isLoading: false, + }, scrollToTop: vi.fn(), startMaker: vi.fn(), startMutationState: { @@ -76,9 +83,13 @@ vi.mock('@tanstack/react-query', () => ({ reset: vi.fn(), } }), - useQuery: vi.fn(() => ({ - refetch: mocks.stopMakerRefetch, - })), + useQuery: vi.fn((options: { queryKey?: unknown[] }) => { + if (options.queryKey?.[0] === 'orderbook') { + mocks.orderbookQueryOptions(options) + return { ...mocks.orderbookQueryState, data: mocks.orderbookData() } + } + return { refetch: mocks.stopMakerRefetch } + }), })) vi.mock('react-i18next', () => ({ @@ -194,9 +205,24 @@ vi.mock('./MoveToJarDialog', () => ({ })) vi.mock('./OfferCard', () => ({ - OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => ( + OfferCard: ({ + children, + nickname, + orderbookStatus, + orderbookOffer, + fidelityBond, + }: { + children?: React.ReactNode + nickname?: string + orderbookStatus?: string + orderbookOffer?: { fidelity_bond_value?: number } + fidelityBond?: { amount?: number } + }) => (
offer-card:{nickname} + orderbook-status:{orderbookStatus} + bond-value:{orderbookOffer?.fidelity_bond_value} + bond-amount:{fidelityBond?.amount} {children}
), @@ -251,6 +277,11 @@ describe('EarnPage', () => { beforeEach(() => { mocks.developerMode = false mocks.feeConfigMissing = false + mocks.orderbookData.mockReset() + mocks.orderbookData.mockReturnValue(undefined) + mocks.orderbookQueryOptions.mockReset() + mocks.orderbookQueryState.isError = false + mocks.orderbookQueryState.isLoading = false mocks.scrollToTop.mockReset() mocks.startMaker.mockReset() mocks.startMaker.mockResolvedValue({}) @@ -342,6 +373,51 @@ describe('EarnPage', () => { expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true }) }) + it('shows the current offer and fidelity bond from the local orderbook', () => { + setSession({ + maker_running: true, + offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }], + }) + mocks.orderbookData.mockReturnValue({ + offers: [{ counterparty: 'maker-a', oid: 7, fidelity_bond_value: 42_000 }], + fidelitybonds: [ + { counterparty: 'maker-a', amount: 50_000, locktime: 1_700_000_000 }, + { counterparty: 'maker-a', amount: 100_000, locktime: 1_800_000_000 }, + ], + }) + + render() + + expect(screen.getByText('orderbook-status:visible')).toBeInTheDocument() + expect(screen.getByText('bond-value:42000')).toBeInTheDocument() + expect(screen.getByText('bond-amount:100000')).toBeInTheDocument() + }) + + it('polls until the current offer is visible, then slows down', () => { + setSession({ + maker_running: true, + offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }], + }) + + render() + + const { refetchInterval } = mocks.orderbookQueryOptions.mock.calls[0][0] as { + refetchInterval: (query: { + state: { + data?: { offers: Array<{ counterparty: string; oid: number }> } + error?: Error | null + } + }) => number + } + const visibleData = { offers: [{ counterparty: 'maker-a', oid: 7 }] } + + expect(refetchInterval({ state: {} })).toBe(JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL) + expect(refetchInterval({ state: { data: visibleData } })).toBe(JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL) + expect(refetchInterval({ state: { data: visibleData, error: new Error('offline') } })).toBe( + JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL, + ) + }) + it('shows waiting states while maker updates', () => { mocks.startMutationState.isSuccess = true diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 27815ec4..6f9b2ab9 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -22,6 +22,7 @@ import { useApiClient } from '@/hooks/useApiClient' import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import { useRefreshSession } from '@/hooks/useRefreshSession' +import * as OrderbookApi from '@/lib/api/orderbook' import { getErrorReason } from '@/lib/errorReason' import * as fb from '@/lib/fidelityBondUtils' import { withQueryDelay } from '@/lib/queryClient' @@ -80,6 +81,40 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false) const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0 + const currentOffer = jmSession?.offer_list?.[0] + const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) => + offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid) + + const { + data: orderbookData, + isFetching: orderbookIsFetching, + isError: orderbookIsError, + } = useQuery({ + queryKey: ['orderbook'], + queryFn: withQueryDelay(OrderbookApi.fetchOrderbook, { + // avoid flickering and let user briefly know that something is happening in the background + delayBefore: 1_000, + }), + enabled: makerRunning && !!currentOffer && !!jmSession?.nickname, + staleTime: Number.POSITIVE_INFINITY, + refetchInterval: (query) => + query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer)) + ? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL + : JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL, + }) + const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer)) + const currentOrderbookFidelityBond = + Number(currentOrderbookOffer?.fidelity_bond_value) > 0 + ? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname) + : undefined + const orderbookStatus = + !jmSession?.nickname || orderbookIsFetching + ? 'checking' + : orderbookIsError + ? 'error' + : currentOrderbookOffer + ? 'visible' + : 'missing' const stopMakerQueryOptions = stopmakerOptions({ client, @@ -261,6 +296,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { className="motion-safe:animate-in blur-in" value={jmSession.offer_list[0]} nickname={jmSession.nickname} + orderbookStatus={orderbookStatus} + orderbookOffer={currentOrderbookOffer} + fidelityBond={currentOrderbookFidelityBond} >