diff --git a/src/components/common/KeyStatsPanel.tsx b/src/components/common/KeyStatsPanel.tsx index b0b3774e..198350e9 100644 --- a/src/components/common/KeyStatsPanel.tsx +++ b/src/components/common/KeyStatsPanel.tsx @@ -1,13 +1,17 @@ import Skeleton from '@/components/ui/skeleton'; import { AccessibleInfoTrigger } from '@/components/common/AccessibleInfoTrigger'; import { KEY_STAT_DEFINITIONS } from '@/components/common/keyStatDefinitions'; -import type { KeyStats } from '@/services/course.service'; +import UniqueTradersStat from '@/components/common/UniqueTradersStat'; +import type { KeyStats, KeyUniqueTraders } from '@/services/course.service'; interface KeyStatsPanelProps { stats?: KeyStats | null; /** True only during the initial fetch (no data yet). */ isLoading?: boolean; isError?: boolean; + /** Unique trader count, fetched separately on a 5-minute interval (#1020). */ + uniqueTraders?: KeyUniqueTraders | null; + isUniqueTradersLoading?: boolean; } /** @@ -21,6 +25,8 @@ const KeyStatsPanel: React.FC = ({ stats, isLoading = false, isError = false, + uniqueTraders, + isUniqueTradersLoading = false, }) => { const showSkeleton = isLoading && !stats; const showError = isError && !stats && !isLoading; @@ -38,7 +44,7 @@ const KeyStatsPanel: React.FC = ({ > Key Stats -
+
{KEY_STAT_DEFINITIONS.map(stat => (
= ({
))} +
{showSkeleton && ( diff --git a/src/components/common/UniqueTradersStat.tsx b/src/components/common/UniqueTradersStat.tsx new file mode 100644 index 00000000..78ea490e --- /dev/null +++ b/src/components/common/UniqueTradersStat.tsx @@ -0,0 +1,99 @@ +import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { AccessibleInfoTrigger } from '@/components/common/AccessibleInfoTrigger'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { + getUniqueTradersTrend, + type UniqueTradersTrend, +} from '@/utils/uniqueTraders.utils'; +import type { KeyUniqueTraders } from '@/services/course.service'; + +export const UNIQUE_TRADERS_LABEL = 'Unique Traders'; +export const UNIQUE_TRADERS_EXPLANATION = + 'Number of distinct wallets that have bought or sold this key at least once.'; + +interface UniqueTradersStatProps { + data?: KeyUniqueTraders | null; + /** True only during the initial fetch (no data yet). */ + isLoading?: boolean; +} + +const TREND_STYLES = { + up: { Icon: ArrowUpRight, className: 'text-emerald-300', word: 'Up' }, + down: { Icon: ArrowDownRight, className: 'text-rose-300', word: 'Down' }, + flat: { Icon: Minus, className: 'text-white/40', word: 'No change' }, +} as const; + +/** + * Unique trader count cell for the key stats panel (#1020). Uses the same + * fixed-height cell as the other stats so the 5-minute refreshes never shift + * the layout. + */ +const UniqueTradersStat: React.FC = ({ + data, + isLoading = false, +}) => { + const showSkeleton = isLoading && !data; + const current = data?.uniqueTraders; + const trend = getUniqueTradersTrend(current, data?.uniqueTraders24hAgo); + + return ( +
+
+ {UNIQUE_TRADERS_LABEL} + +
+
+ {showSkeleton ? ( + + ) : ( + <> + + {formatNumber(current)} + + {trend && } + + )} +
+
+ ); +}; + +const TrendIndicator: React.FC = ({ direction, delta }) => { + const { Icon, className, word } = TREND_STYLES[direction]; + const label = + direction === 'flat' + ? 'No change vs previous 24 hours' + : `${word} ${formatNumber(delta)} vs previous 24 hours`; + const sign = direction === 'up' ? '+' : direction === 'down' ? '−' : ''; + + return ( + + + ); +}; + +export default UniqueTradersStat; diff --git a/src/components/common/__tests__/KeyStatsPanel.test.tsx b/src/components/common/__tests__/KeyStatsPanel.test.tsx index c663e4d3..31993b8f 100644 --- a/src/components/common/__tests__/KeyStatsPanel.test.tsx +++ b/src/components/common/__tests__/KeyStatsPanel.test.tsx @@ -138,10 +138,26 @@ describe('KeyStatsPanel', () => { expect(grid).toHaveClass( 'grid', 'grid-cols-2', - 'sm:grid-cols-3', - 'lg:grid-cols-6' + 'sm:grid-cols-4', + 'xl:grid-cols-7' ); - expect(within(grid as HTMLElement).getAllByRole('term')).toHaveLength(6); + // Six aggregated stats plus the unique traders cell (#1020). + expect(within(grid as HTMLElement).getAllByRole('term')).toHaveLength(7); + }); + + it('renders the unique traders stat alongside holders and total volume', () => { + render( + + ); + expect( + screen.getByTestId('key-stat-uniqueTraders-value') + ).toHaveTextContent('312'); + expect( + screen.getByTestId('key-stat-uniqueTraders-trend') + ).toHaveAttribute('data-direction', 'up'); }); it('shows an error message when the initial fetch fails', () => { diff --git a/src/components/common/__tests__/UniqueTradersStat.test.tsx b/src/components/common/__tests__/UniqueTradersStat.test.tsx new file mode 100644 index 00000000..97984c5e --- /dev/null +++ b/src/components/common/__tests__/UniqueTradersStat.test.tsx @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import UniqueTradersStat, { + UNIQUE_TRADERS_EXPLANATION, +} from '@/components/common/UniqueTradersStat'; + +const renderStat = (props: React.ComponentProps) => + render( +
+ +
+ ); + +describe('UniqueTradersStat', () => { + it('displays the unique trader count from the API', () => { + renderStat({ data: { uniqueTraders: 1234, uniqueTraders24hAgo: 1200 } }); + expect( + screen.getByTestId('key-stat-uniqueTraders-value') + ).toHaveTextContent('1,234'); + expect(screen.getByText('Unique Traders')).toBeInTheDocument(); + }); + + it('shows an upward trend vs the previous 24 hours', () => { + renderStat({ data: { uniqueTraders: 50, uniqueTraders24hAgo: 42 } }); + const trend = screen.getByTestId('key-stat-uniqueTraders-trend'); + expect(trend).toHaveAttribute('data-direction', 'up'); + expect(trend).toHaveTextContent('+8'); + expect( + screen.getByRole('img', { name: 'Up 8 vs previous 24 hours' }) + ).toBeInTheDocument(); + }); + + it('shows a downward trend vs the previous 24 hours', () => { + renderStat({ data: { uniqueTraders: 40, uniqueTraders24hAgo: 42 } }); + const trend = screen.getByTestId('key-stat-uniqueTraders-trend'); + expect(trend).toHaveAttribute('data-direction', 'down'); + expect(trend).toHaveTextContent('−2'); + expect( + screen.getByRole('img', { name: 'Down 2 vs previous 24 hours' }) + ).toBeInTheDocument(); + }); + + it('renders zero traders for a new key with a no-change trend', () => { + renderStat({ data: { uniqueTraders: 0, uniqueTraders24hAgo: 0 } }); + expect( + screen.getByTestId('key-stat-uniqueTraders-value') + ).toHaveTextContent('0'); + expect( + screen.getByTestId('key-stat-uniqueTraders-trend') + ).toHaveAttribute('data-direction', 'flat'); + expect( + screen.getByRole('img', { name: 'No change vs previous 24 hours' }) + ).toBeInTheDocument(); + }); + + it('hides the trend when the previous 24h value is unavailable', () => { + renderStat({ data: { uniqueTraders: 5, uniqueTraders24hAgo: null } }); + expect( + screen.queryByTestId('key-stat-uniqueTraders-trend') + ).not.toBeInTheDocument(); + }); + + it('exposes an accessible tooltip explaining the metric', () => { + renderStat({ data: { uniqueTraders: 5, uniqueTraders24hAgo: 5 } }); + const trigger = screen.getByRole('button', { + name: 'Explanation for: Unique Traders', + }); + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toHaveTextContent( + UNIQUE_TRADERS_EXPLANATION + ); + expect(UNIQUE_TRADERS_EXPLANATION).toMatch(/distinct wallets/); + expect(UNIQUE_TRADERS_EXPLANATION).toMatch(/bought or sold/); + }); + + it('keeps a fixed-height value slot between loading and loaded states', () => { + const { rerender } = renderStat({ isLoading: true }); + const cell = screen.getByTestId('key-stat-uniqueTraders'); + const loadingClass = cell.querySelector('dd')?.className; + expect( + screen.queryByTestId('key-stat-uniqueTraders-value') + ).not.toBeInTheDocument(); + + rerender( +
+ +
+ ); + expect(cell.querySelector('dd')?.className).toBe(loadingClass); + expect(loadingClass).toContain('h-7'); + }); + + it('keeps showing the value during a background refresh', () => { + renderStat({ + data: { uniqueTraders: 7, uniqueTraders24hAgo: 7 }, + isLoading: true, + }); + expect( + screen.getByTestId('key-stat-uniqueTraders-value') + ).toHaveTextContent('7'); + }); +}); diff --git a/src/hooks/__tests__/useKeyUniqueTraders.test.tsx b/src/hooks/__tests__/useKeyUniqueTraders.test.tsx new file mode 100644 index 00000000..ed9d7369 --- /dev/null +++ b/src/hooks/__tests__/useKeyUniqueTraders.test.tsx @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { + useKeyUniqueTraders, + KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS, +} from '@/hooks/useKeyUniqueTraders'; +import { + courseService, + type KeyUniqueTraders, +} from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getKeyUniqueTraders: vi.fn(), + }, +})); + +const mockGet = vi.mocked(courseService.getKeyUniqueTraders); + +const base: KeyUniqueTraders = { uniqueTraders: 20, uniqueTraders24hAgo: 18 }; + +describe('useKeyUniqueTraders', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockGet.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('fetches the count for the key and refreshes every 5 minutes', async () => { + mockGet.mockResolvedValue(base); + + const { result } = renderHook(() => useKeyUniqueTraders('key-1'), { + wrapper, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(base); + expect(mockGet).toHaveBeenCalledWith('key-1'); + + const query = queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.uniqueTraders('key-1') }); + expect(KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS).toBe(300_000); + expect(query?.options.staleTime).toBe(300_000); + expect(query?.options.refetchInterval).toBe(300_000); + }); + + it('refetches after 5 minutes and keeps previous data while refreshing', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockGet.mockResolvedValueOnce(base); + + const { result } = renderHook(() => useKeyUniqueTraders('key-1'), { + wrapper, + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const updated = { ...base, uniqueTraders: 21 }; + let resolveRefetch!: (value: KeyUniqueTraders) => void; + mockGet.mockReturnValueOnce( + new Promise(resolve => { + resolveRefetch = resolve; + }) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300_000); + }); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(base); + + await act(async () => { + resolveRefetch(updated); + }); + await waitFor(() => expect(result.current.data).toEqual(updated)); + }); + + it('does not fetch when the key id is empty', () => { + renderHook(() => useKeyUniqueTraders(''), { wrapper }); + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useKeyUniqueTraders.ts b/src/hooks/useKeyUniqueTraders.ts new file mode 100644 index 00000000..15104ac5 --- /dev/null +++ b/src/hooks/useKeyUniqueTraders.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +/** How often the unique trader count is refreshed (#1020). */ +export const KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS = 5 * 60_000; + +export function useKeyUniqueTraders(keyId: string) { + return useQuery({ + queryKey: queryKeys.creators.uniqueTraders(keyId), + queryFn: () => courseService.getKeyUniqueTraders(keyId), + enabled: !!keyId, + staleTime: KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS, + refetchInterval: KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS, + retry: false, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 28ed0221..250bae94 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -30,6 +30,8 @@ export const queryKeys = { twap: (creatorId: string) => ['creators', creatorId, 'twap', '24h'] as const, stats: (creatorId: string) => ['creators', creatorId, 'stats'] as const, + uniqueTraders: (creatorId: string) => + ['creators', creatorId, 'unique-traders'] as const, curveConfig: (creatorId: string) => ['creators', creatorId, 'curve-config'] as const, curveMigrations: (creatorId: string) => @@ -74,7 +76,7 @@ export const queryKeys = { all: () => ['leaderboard'] as const, volume: (window: VolumeWindow = '24h') => ['leaderboard', 'volume', window] as const, - ratings: () => ['leaderboard', 'ratings'] as const, + ratings: () => ['leaderboard', 'ratings'] as const, }, admin: { oracleCallers: () => ['admin', 'oracle', 'callers'] as const, @@ -116,7 +118,8 @@ export const queryKeys = { ['atomic-swap', 'proposal', proposalId] as const, proposals: (address: string, status?: string) => ['atomic-swap', 'proposals', address, status ?? 'all'] as const, - history: (address: string) => ['atomic-swap', 'history', address] as const, + history: (address: string) => + ['atomic-swap', 'history', address] as const, }, bundles: { all: (creatorId: string) => ['bundles', creatorId] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 8e0f5e9d..1d187ea4 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -43,6 +43,7 @@ import { usePurchaseConfetti } from '@/hooks/usePurchaseConfetti'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { useKeyTwap } from '@/hooks/useKeyTwap'; import { useKeyStats } from '@/hooks/useKeyStats'; +import { useKeyUniqueTraders } from '@/hooks/useKeyUniqueTraders'; import { useKeyConfig } from '@/hooks/useKeyConfig'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; @@ -115,6 +116,8 @@ function CreatorDetailPageContent() { isLoading: isKeyStatsLoading, isError: isKeyStatsError, } = useKeyStats(id || ''); + const { data: uniqueTraders, isLoading: isUniqueTradersLoading } = + useKeyUniqueTraders(id || ''); // Live key config powers the bid-ask spread shown next to the buy // action and inside the trade dialog (#951). const { data: keyConfig, isLoading: isKeyConfigLoading } = useKeyConfig( @@ -346,6 +349,8 @@ function CreatorDetailPageContent() { stats={keyStats} isLoading={isKeyStatsLoading} isError={isKeyStatsError} + uniqueTraders={uniqueTraders} + isUniqueTradersLoading={isUniqueTradersLoading} /> {/* Performance Bond Status Panel (#975) */} { setRecentSettlement(receipt); }} /> )} - {creator && ( ); -} \ No newline at end of file +} diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 28f7ee28..de6b395d 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -301,6 +301,17 @@ export interface KeyStats { twap24h: number | null; } +/** + * Unique trader count for a creator key (#1020): distinct wallets that have + * bought or sold the key at least once. + */ +export interface KeyUniqueTraders { + /** Current all-time unique trader count. */ + uniqueTraders: number | null; + /** Unique trader count as of 24 hours ago, used for the trend indicator. */ + uniqueTraders24hAgo: number | null; +} + class CourseService extends BaseApiService { private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds @@ -429,6 +440,18 @@ class CourseService extends BaseApiService { } } + // Get the unique trader count - GET /keys/:keyId/unique-traders + async getKeyUniqueTraders(keyId: string): Promise { + try { + const response = await this.api.get>( + `/keys/${keyId}/unique-traders` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } + // Get enrolled courses - GET /courses/enrolled async getEnrolledCourses(): Promise { try { @@ -533,10 +556,9 @@ class CourseService extends BaseApiService { quantity: number ): Promise> { try { - const response = await this.api.get>>( - `/keys/${keyId}/simulate`, - { params: { quantity } } - ); + const response = await this.api.get< + APIResponse> + >(`/keys/${keyId}/simulate`, { params: { quantity } }); return response.data.data; } catch (error) { throw this.handleError(error); diff --git a/src/utils/__tests__/uniqueTraders.utils.test.ts b/src/utils/__tests__/uniqueTraders.utils.test.ts new file mode 100644 index 00000000..61fc1dcf --- /dev/null +++ b/src/utils/__tests__/uniqueTraders.utils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { getUniqueTradersTrend } from '@/utils/uniqueTraders.utils'; + +describe('getUniqueTradersTrend', () => { + it('reports an upward change', () => { + expect(getUniqueTradersTrend(15, 10)).toEqual({ + direction: 'up', + delta: 5, + }); + }); + + it('reports a downward change as a positive delta', () => { + expect(getUniqueTradersTrend(8, 10)).toEqual({ + direction: 'down', + delta: 2, + }); + }); + + it('reports no change, including for new keys with zero traders', () => { + expect(getUniqueTradersTrend(10, 10)).toEqual({ + direction: 'flat', + delta: 0, + }); + expect(getUniqueTradersTrend(0, 0)).toEqual({ + direction: 'flat', + delta: 0, + }); + }); + + it('returns null when either value is missing or not finite', () => { + expect(getUniqueTradersTrend(null, 10)).toBeNull(); + expect(getUniqueTradersTrend(10, undefined)).toBeNull(); + expect(getUniqueTradersTrend(Number.NaN, 10)).toBeNull(); + }); +}); diff --git a/src/utils/uniqueTraders.utils.ts b/src/utils/uniqueTraders.utils.ts new file mode 100644 index 00000000..4f11b16c --- /dev/null +++ b/src/utils/uniqueTraders.utils.ts @@ -0,0 +1,24 @@ +export type TrendDirection = 'up' | 'down' | 'flat'; + +export interface UniqueTradersTrend { + direction: TrendDirection; + /** Absolute change vs 24 hours ago. */ + delta: number; +} + +/** + * Compares the current unique trader count with the count from 24 hours ago + * (#1020). Returns null when either value is missing, so no trend is shown + * rather than a misleading one. + */ +export function getUniqueTradersTrend( + current: number | null | undefined, + previous: number | null | undefined +): UniqueTradersTrend | null { + if (current == null || previous == null) return null; + if (!Number.isFinite(current) || !Number.isFinite(previous)) return null; + const diff = current - previous; + if (diff > 0) return { direction: 'up', delta: diff }; + if (diff < 0) return { direction: 'down', delta: -diff }; + return { direction: 'flat', delta: 0 }; +}