diff --git a/src/App.tsx b/src/App.tsx index b3c21a8e..3ade8cf9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import OfflineBanner from './components/common/OfflineBanner'; import GlobalPauseBanner from './components/common/GlobalPauseBanner'; import PendingTransactionBanner from './components/common/PendingTransactionBanner'; import SessionExpiryWatcher from './components/common/SessionExpiryWatcher'; +import BadgeAchievementListener from './components/common/BadgeAchievementListener'; import { useContractPausedStore } from './hooks/useContractPausedStore'; import { routes } from './routes'; import { useRouteChangeLogging } from './hooks/useRouteChangeLogging'; @@ -56,6 +57,8 @@ function App() { }} /> router.navigate(path)} /> + {/* Badge achievement celebrations, app-wide (#1222) */} + router.navigate(path)} /> ); diff --git a/src/components/common/BadgeAchievementListener.tsx b/src/components/common/BadgeAchievementListener.tsx new file mode 100644 index 00000000..54646199 --- /dev/null +++ b/src/components/common/BadgeAchievementListener.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useAccount } from 'wagmi'; +import { useBadgeAchievements } from '@/hooks/useBadgeAchievements'; + +export interface BadgeAchievementListenerProps { + /** + * Navigates to the given path. Passed in (rather than pulled from + * `useNavigate`) so this component can be mounted at the app root, outside + * the `RouterProvider` tree where router hooks aren't available — + * `App.tsx` passes the `router.navigate` bound method from the same + * `createBrowserRouter` instance it renders. + */ + navigate: (path: string) => void; +} + +/** + * Mounted once at the app root so badge achievements are celebrated wherever + * the user happens to be (#1222). + * + * Watches the connected wallet for newly earned badges and shows the + * achievement toast. The toast's "View Badges" CTA deep-links to the + * wallet's badge showcase on the profile page. + */ +export default function BadgeAchievementListener({ + navigate, +}: BadgeAchievementListenerProps) { + const { address } = useAccount(); + + const handleViewBadges = useCallback( + (profilePath: string) => { + navigate(profilePath); + }, + [navigate] + ); + + useBadgeAchievements(address, { onViewBadges: handleViewBadges }); + + return null; +} diff --git a/src/components/common/KeyDepthChart.tsx b/src/components/common/KeyDepthChart.tsx new file mode 100644 index 00000000..5e921c75 --- /dev/null +++ b/src/components/common/KeyDepthChart.tsx @@ -0,0 +1,380 @@ +import { useMemo } from 'react'; +import { + Area, + AreaChart, + CartesianGrid, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import Skeleton from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { useKeyDepth } from '@/hooks/useKeyDepth'; +import { KEY_DEPTH_LEVELS } from '@/services/keyDepth.service'; +import { + buildDepthChartModel, + formatDepthPrice, + formatDepthPriceTick, + type DepthChartPoint, +} from '@/utils/keyDepthChart.utils'; +import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; + +export interface KeyDepthChartProps { + /** Key whose order book is charted. */ + keyId: string; + /** Number of price levels requested per side of the book. */ + levels?: number; + className?: string; +} + +const BID_COLOR = '#34d399'; +const ASK_COLOR = '#f87171'; + +/** + * Market depth chart for a key (#1220). + * + * Renders the open buy orders (bids, green) and sell listings (asks, red) as + * two cumulative step areas mirrored around the mid price: the bid ladder + * climbs to the left of the mid marker, the ask ladder climbs to the right, + * and the x-axis shows the price levels. The snapshot is polled every five + * seconds and updated in place, so the chart never re-renders from scratch. + */ +export function KeyDepthChart({ + keyId, + levels = KEY_DEPTH_LEVELS, + className, +}: KeyDepthChartProps) { + const isMobile = useIsMobile(); + const { data, isLoading, isError, isFetching } = useKeyDepth(keyId, levels); + + const model = useMemo(() => buildDepthChartModel(data), [data]); + + // The mid marker is centred on the plot area, so the overlay that carries + // the spread badge has to use the very same insets as the chart itself. + const yAxisWidth = isMobile ? 40 : 48; + const chartMargin = { top: 44, right: 12, left: 0, bottom: 0 }; + const plotCentreStyle = { + left: `calc(${yAxisWidth}px + (100% - ${yAxisWidth + chartMargin.right}px) / 2)`, + }; + + const hasData = model.points.length > 0; + const showSkeleton = isLoading && !data; + const showError = isError && !data && !isLoading; + const showEmpty = !showSkeleton && !showError && !hasData; + + const spreadAmount = formatDepthPrice(model.spread); + const spreadPercentLabel = + model.spreadPercent == null ? '' : ` (${model.spreadPercent.toFixed(2)}%)`; + const spreadLabel = + model.spread == null ? '—' : `${spreadAmount}${spreadPercentLabel}`; + + return ( +
+
+
+

+ Market depth +

+ {isFetching && !isLoading && ( + + + )} +
+ +
+ + + + +
+
+ + {/* Book summary above the chart (#1220) */} +
+
+ + Best bid + + + {hasData ? formatDepthPrice(model.bestBid) : '—'} + +
+ +
+ + Best ask + + + {hasData ? formatDepthPrice(model.bestAsk) : '—'} + +
+ +
+ + Mid price + + + {hasData ? formatDepthPrice(model.midPrice) : '—'} + +
+ +
+ + Spread + + + {hasData ? spreadLabel : '—'} + +
+
+ + {showSkeleton ? ( +
+ +
+ ) : showError ? ( +
+ Market depth is temporarily unavailable. +
+ ) : showEmpty ? ( +
+ No open orders for this key yet +
+ ) : ( +
+ + + + + + + + + + + + + + + + + formatCompactNumber(value) + } + /> + + formatDepthPrice(Number(label)) + } + formatter={( + value: unknown, + name: unknown, + item: unknown + ): [string, string] | null => { + if (value == null) return null; + + const point = (item as { payload?: DepthChartPoint }) + ?.payload; + const isBid = name === 'bidVolume'; + const levelVolume = point + ? isBid + ? point.bidAmount + : point.askAmount + : 0; + const cumulative = + typeof value === 'number' + ? value + : Number(value); + + return [ + `${formatNumber(levelVolume)} keys · ${formatNumber(cumulative)} cumulative`, + isBid ? 'Bids' : 'Asks', + ]; + }} + /> + + {model.midPrice != null && ( + + )} + + + + + + + {/* Mid-price marker and spread badge, centred on the plot (#1220) */} + {model.midPrice != null && ( + + )} +
+ )} + +

+ Price per fraction (XLM) · Cumulative volume (keys) · refreshed every 5s +

+
+ ); +} + +export default KeyDepthChart; diff --git a/src/components/common/WalletBadgeShowcase.tsx b/src/components/common/WalletBadgeShowcase.tsx index 3edeae5b..3c089315 100644 --- a/src/components/common/WalletBadgeShowcase.tsx +++ b/src/components/common/WalletBadgeShowcase.tsx @@ -29,9 +29,10 @@ export default function WalletBadgeShowcase({ return (

({ + useBadgeAchievements: vi.fn(), +})); + +describe('BadgeAchievementListener (#1222)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useAccount).mockReturnValue({ + address: '0xabc', + isConnected: true, + } as unknown as ReturnType); + vi.mocked(useBadgeAchievements).mockReturnValue({ announced: [] }); + }); + + it('watches the connected wallet', () => { + render(); + + expect(useBadgeAchievements).toHaveBeenCalledWith( + '0xabc', + expect.objectContaining({ onViewBadges: expect.any(Function) }) + ); + }); + + it('does not watch anything while no wallet is connected', () => { + vi.mocked(useAccount).mockReturnValue({ + address: undefined, + isConnected: false, + } as unknown as ReturnType); + + render(); + + expect(useBadgeAchievements).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ onViewBadges: expect.any(Function) }) + ); + }); + + it('navigates to the badge showcase when the toast CTA is used', () => { + const navigate = vi.fn(); + render(); + + const options = vi.mocked(useBadgeAchievements).mock.calls[0][1]; + options?.onViewBadges?.('/profile/0xabc#wallet-badges'); + + expect(navigate).toHaveBeenCalledWith('/profile/0xabc#wallet-badges'); + }); +}); diff --git a/src/components/common/__tests__/KeyDepthChart.test.tsx b/src/components/common/__tests__/KeyDepthChart.test.tsx new file mode 100644 index 00000000..5d55b21a --- /dev/null +++ b/src/components/common/__tests__/KeyDepthChart.test.tsx @@ -0,0 +1,159 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { KeyDepthChart } from '../KeyDepthChart'; +import { fetchKeyDepth, type KeyDepthSnapshot } from '@/services/keyDepth.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +const SNAPSHOT = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return { + queryClient, + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }; +} + +describe('KeyDepthChart (#1220)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchKeyDepth).mockResolvedValue(SNAPSHOT); + }); + + it('shows a loading skeleton while the initial fetch is pending', () => { + vi.mocked(fetchKeyDepth).mockImplementation( + () => new Promise(() => {}) + ); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + expect(screen.getByTestId('key-depth-chart-skeleton')).toBeInTheDocument(); + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('—'); + }); + + it('requests 20 levels from the depth endpoint', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(fetchKeyDepth).toHaveBeenCalledWith('key-1', 20); + }); + }); + + it('renders the book summary once the depth resolves', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('1.05 XLM'); + expect(screen.getByTestId('key-depth-best-ask')).toHaveTextContent('1.15 XLM'); + expect(screen.getByTestId('key-depth-mid-price')).toHaveTextContent('1.10 XLM'); + expect(screen.getByTestId('key-depth-spread')).toHaveTextContent( + '0.1000 XLM (9.09%)' + ); + }); + + it('marks the mid price on the chart and shows the spread at the centre', async () => { + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-mid-marker')).toBeInTheDocument(); + }); + + const marker = screen.getByTestId('key-depth-chart-mid-marker'); + expect(marker).toHaveAttribute('data-mid-price', '1.1'); + expect(marker).toHaveAttribute('data-best-bid', '1.05'); + expect(marker).toHaveAttribute('data-best-ask', '1.15'); + expect( + screen.getByTestId('key-depth-chart-spread-marker') + ).toHaveTextContent('Spread 0.1000 XLM'); + }); + + it('shows the empty state when the key has no open orders', async () => { + vi.mocked(fetchKeyDepth).mockResolvedValue({ + bids: [], + asks: [], + midPrice: null, + spread: null, + }); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-empty')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('key-depth-chart-series')).not.toBeInTheDocument(); + }); + + it('shows an error state when the depth request fails', async () => { + vi.mocked(fetchKeyDepth).mockRejectedValue(new Error('Network error')); + const { wrapper } = makeWrapper(); + + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-error')).toBeInTheDocument(); + }); + }); + + it('keeps the previous snapshot on screen while a refresh is in flight', async () => { + const { wrapper, queryClient } = makeWrapper(); + + render(, { wrapper }); + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + }); + + // The next poll never resolves: the chart must not fall back to the + // skeleton, it keeps rendering the last snapshot. + vi.mocked(fetchKeyDepth).mockImplementationOnce( + () => new Promise(() => {}) + ); + + await act(async () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.creators.depth('key-1', 20), + }); + }); + + expect(screen.getByTestId('key-depth-chart-series')).toBeInTheDocument(); + expect(screen.queryByTestId('key-depth-chart-skeleton')).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByTestId('key-depth-chart-refreshing') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/hooks/__tests__/useBadgeAchievements.test.tsx b/src/hooks/__tests__/useBadgeAchievements.test.tsx new file mode 100644 index 00000000..50784604 --- /dev/null +++ b/src/hooks/__tests__/useBadgeAchievements.test.tsx @@ -0,0 +1,245 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { useBadgeAchievements } from '@/hooks/useBadgeAchievements'; +import { useWalletBadges } from '@/hooks/useWallet'; +import { badgeEventSocket } from '@/services/badgeEvents.service'; +import { showBadgeEarnedToast } from '@/utils/badgeAchievementToast'; +import { queryKeys } from '@/lib/queryKeys'; +import type { WalletBadge, WalletBadgeType } from '@/utils/walletBadges.utils'; + +vi.mock('@/hooks/useWallet', () => ({ + useWalletBadges: vi.fn(), +})); + +vi.mock('@/services/badgeEvents.service', () => ({ + badgeEventSocket: { subscribe: vi.fn() }, +})); + +vi.mock('@/utils/badgeAchievementToast', () => ({ + showBadgeEarnedToast: vi.fn(), +})); + +const WALLET = '0xabc'; + +type BadgeHandler = (event: { + wallet: string; + badgeType: WalletBadgeType; + earnedAt: string; +}) => void; + +let badgeHandler: BadgeHandler | null = null; + +function mockBadges(badges: WalletBadge[]) { + vi.mocked(useWalletBadges).mockReturnValue({ + data: badges, + } as unknown as ReturnType); +} + +describe('useBadgeAchievements (#1222)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + badgeHandler = null; + + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined); + + vi.mocked(badgeEventSocket.subscribe).mockImplementation( + ((_wallet: string, handler: BadgeHandler) => { + badgeHandler = handler; + return () => {}; + }) as unknown as typeof badgeEventSocket.subscribe + ); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('does not celebrate badges that were already earned', () => { + mockBadges([{ type: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }]); + + renderHook(() => useBadgeAchievements(WALLET), { wrapper }); + + expect(showBadgeEarnedToast).not.toHaveBeenCalled(); + }); + + it('does not celebrate pre-existing badges when a mint event arrives first', async () => { + // The wallet has not loaded its badges yet, so nothing is seeded. + mockBadges(undefined as unknown as WalletBadge[]); + const { rerender } = renderHook(() => useBadgeAchievements(WALLET), { + wrapper, + }); + + await waitFor(() => expect(badgeHandler).not.toBeNull()); + act(() => { + badgeHandler?.({ + wallet: WALLET, + badgeType: 'whale', + earnedAt: '2026-09-02T00:00:00Z', + }); + }); + + await waitFor(() => { + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + + // The first payload lands afterwards and still contains a badge that + // was earned before the session — it must not be celebrated. + mockBadges([ + { type: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }, + { type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }, + ]); + rerender(); + + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + + it('celebrates a badge that appears on a later poll', async () => { + mockBadges([]); + const { rerender } = renderHook(() => useBadgeAchievements(WALLET), { + wrapper, + }); + + mockBadges([{ type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }]); + rerender(); + + await waitFor(() => { + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + expect(showBadgeEarnedToast).toHaveBeenCalledWith( + expect.objectContaining({ + badgeType: 'whale', + profilePath: `/profile/${WALLET}#wallet-badges`, + }) + ); + }); + + it('celebrates a badge when the realtime feed reports a mint', async () => { + mockBadges([]); + + renderHook(() => useBadgeAchievements(WALLET), { wrapper }); + + await waitFor(() => expect(badgeHandler).not.toBeNull()); + + act(() => { + badgeHandler?.({ + wallet: WALLET, + badgeType: 'top-trader', + earnedAt: '2026-09-03T00:00:00Z', + }); + }); + + await waitFor(() => { + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + expect(showBadgeEarnedToast).toHaveBeenCalledWith( + expect.objectContaining({ badgeType: 'top-trader' }) + ); + }); + + it('ignores events addressed to another wallet', async () => { + mockBadges([]); + + renderHook(() => useBadgeAchievements(WALLET), { wrapper }); + await waitFor(() => expect(badgeHandler).not.toBeNull()); + + act(() => { + badgeHandler?.({ + wallet: '0xsomeone-else', + badgeType: 'whale', + earnedAt: '2026-09-03T00:00:00Z', + }); + }); + + expect(showBadgeEarnedToast).not.toHaveBeenCalled(); + }); + + it('never celebrates the same badge twice', async () => { + mockBadges([]); + const { rerender } = renderHook(() => useBadgeAchievements(WALLET), { + wrapper, + }); + + mockBadges([{ type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }]); + rerender(); + + await waitFor(() => { + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + + // The realtime feed repeats the same mint. + await waitFor(() => expect(badgeHandler).not.toBeNull()); + act(() => { + badgeHandler?.({ + wallet: WALLET, + badgeType: 'whale', + earnedAt: '2026-09-02T00:00:00Z', + }); + }); + + // And the next poll returns the same book. + mockBadges([{ type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }]); + rerender(); + + expect(showBadgeEarnedToast).toHaveBeenCalledTimes(1); + }); + + it('puts the new badge in the cached book so the showcase updates at once', async () => { + mockBadges([]); + queryClient.setQueryData(queryKeys.wallet.badges(WALLET), [ + { type: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }, + ]); + + renderHook(() => useBadgeAchievements(WALLET), { wrapper }); + await waitFor(() => expect(badgeHandler).not.toBeNull()); + + act(() => { + badgeHandler?.({ + wallet: WALLET, + badgeType: 'whale', + earnedAt: '2026-09-02T00:00:00Z', + }); + }); + + await waitFor(() => { + expect(queryClient.getQueryData(queryKeys.wallet.badges(WALLET))).toEqual([ + { type: 'first-key', earnedAt: '2026-09-01T00:00:00Z' }, + { type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }, + ]); + }); + }); + + it('refreshes the badge showcase and progress meters when a badge is earned', async () => { + mockBadges([]); + const { rerender } = renderHook(() => useBadgeAchievements(WALLET), { + wrapper, + }); + + mockBadges([{ type: 'whale', earnedAt: '2026-09-02T00:00:00Z' }]); + rerender(); + + await waitFor(() => { + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.wallet.badges(WALLET), + }); + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.wallet.badgeMetrics(WALLET), + }); + }); + + it('does nothing without a connected wallet', () => { + mockBadges([]); + + renderHook(() => useBadgeAchievements(undefined), { wrapper }); + + expect(badgeEventSocket.subscribe).not.toHaveBeenCalled(); + expect(showBadgeEarnedToast).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useKeyDepth.test.tsx b/src/hooks/__tests__/useKeyDepth.test.tsx new file mode 100644 index 00000000..d0ab3283 --- /dev/null +++ b/src/hooks/__tests__/useKeyDepth.test.tsx @@ -0,0 +1,109 @@ +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 { + useKeyDepth, + KEY_DEPTH_REFRESH_INTERVAL_MS, +} from '@/hooks/useKeyDepth'; +import { fetchKeyDepth, type KeyDepthSnapshot } from '@/services/keyDepth.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +const mockFetchKeyDepth = vi.mocked(fetchKeyDepth); + +const baseSnapshot: KeyDepthSnapshot = { + bids: [{ price: 1.05, volume: 10 }], + asks: [{ price: 1.15, volume: 5 }], + midPrice: null, + spread: null, +}; + +describe('useKeyDepth (#1220)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockFetchKeyDepth.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('fetches the depth for the key and configures a 5 second refresh', async () => { + mockFetchKeyDepth.mockResolvedValue(baseSnapshot); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(baseSnapshot); + expect(mockFetchKeyDepth).toHaveBeenCalledWith('key-1', 20); + + const query = queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.depth('key-1', 20) }); + expect(KEY_DEPTH_REFRESH_INTERVAL_MS).toBe(5_000); + expect(query?.options.staleTime).toBe(5_000); + expect(query?.options.refetchInterval).toBe(5_000); + }); + + it('refetches after 5 seconds and keeps previous data while refreshing', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockFetchKeyDepth.mockResolvedValueOnce(baseSnapshot); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const updated: KeyDepthSnapshot = { + ...baseSnapshot, + bids: [{ price: 1.06, volume: 12 }], + }; + let resolveRefetch!: (value: KeyDepthSnapshot) => void; + mockFetchKeyDepth.mockReturnValueOnce( + new Promise(resolve => { + resolveRefetch = resolve; + }) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(mockFetchKeyDepth).toHaveBeenCalledTimes(2); + // Background refresh must not drop existing data (no skeleton flash). + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(baseSnapshot); + + await act(async () => { + resolveRefetch(updated); + }); + await waitFor(() => expect(result.current.data).toEqual(updated)); + }); + + it('does not fetch when the key id is empty', () => { + renderHook(() => useKeyDepth(''), { wrapper }); + expect(mockFetchKeyDepth).not.toHaveBeenCalled(); + }); + + it('surfaces errors', async () => { + mockFetchKeyDepth.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useKeyDepth('key-1'), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe('Network error'); + }); +}); diff --git a/src/hooks/useBadgeAchievements.ts b/src/hooks/useBadgeAchievements.ts new file mode 100644 index 00000000..7cfdfbbf --- /dev/null +++ b/src/hooks/useBadgeAchievements.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { useWalletBadges } from '@/hooks/useWallet'; +import { + badgeEventSocket, + type BadgeMintedEvent, +} from '@/services/badgeEvents.service'; +import { showBadgeEarnedToast } from '@/utils/badgeAchievementToast'; +import type { + WalletBadge, + WalletBadgeType, +} from '@/utils/walletBadges.utils'; + +export interface UseBadgeAchievementsOptions { + /** Invoked when the user taps "View Badges" in the achievement toast. */ + onViewBadges?: (profilePath: string) => void; +} + +export interface UseBadgeAchievementsResult { + /** Badge types celebrated during this session, in the order they arrived. */ + announced: WalletBadgeType[]; +} + +/** Path to a wallet's badge showcase on the profile page. */ +export function badgeShowcasePath(wallet: string): string { + return `/profile/${encodeURIComponent(wallet)}#wallet-badges`; +} + +interface BadgeTracker { + /** Wallet this tracker follows. */ + wallet: string; + /** + * Badge types already accounted for — earned before the session started + * or already celebrated. Used to keep every badge to a single toast. + */ + known: Set; + /** Badge types that actually got celebrated. */ + celebrated: Set; + /** Whether the first badges payload for this wallet has been seen. */ + seeded: boolean; +} + +/** + * Watches a wallet for newly earned badges and celebrates them (#1222). + * + * Two detection paths feed the same announcement: + * + * 1. The realtime feed pushes a `BadgeMinted` event for the connected wallet. + * 2. The badges endpoint is polled, so a badge minted while the realtime feed + * is unavailable is still picked up on the next poll. + * + * Announcing a badge refreshes the badge showcase and the badge progress + * meters, so the profile updates without a page reload. Badges that were + * already earned when the wallet was first seen are never celebrated. + */ +export function useBadgeAchievements( + wallet: string | undefined, + options: UseBadgeAchievementsOptions = {} +): UseBadgeAchievementsResult { + const queryClient = useQueryClient(); + const { data: badges } = useWalletBadges(wallet ?? ''); + + const trackerRef = useRef(null); + const [announced, setAnnounced] = useState([]); + const onViewBadgesRef = useRef(options.onViewBadges); + + useEffect(() => { + onViewBadgesRef.current = options.onViewBadges; + }, [options.onViewBadges]); + + const getTracker = useCallback((address: string): BadgeTracker => { + const current = trackerRef.current; + if (current && current.wallet === address) return current; + + const tracker: BadgeTracker = { + wallet: address, + known: new Set(), + celebrated: new Set(), + seeded: false, + }; + trackerRef.current = tracker; + return tracker; + }, []); + + const announce = useCallback( + (badgeType: WalletBadgeType, earnedAt?: string) => { + if (!wallet) return; + + const tracker = getTracker(wallet); + + // A badge is only ever celebrated once. + if (tracker.known.has(badgeType)) return; + tracker.known.add(badgeType); + tracker.celebrated.add(badgeType); + setAnnounced([...tracker.celebrated]); + + if (earnedAt) { + // Put the badge in the cached book straight away so the + // showcase shows it immediately, then let the refetch below + // confirm it against the server. + queryClient.setQueryData( + queryKeys.wallet.badges(wallet), + current => { + const badges = current ?? []; + if (badges.some(badge => badge.type === badgeType)) { + return current; + } + return [...badges, { type: badgeType, earnedAt }]; + } + ); + } + + // Refresh the showcase and progress meters so the new badge shows + // up straight away, without a page reload. + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.badges(wallet), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.badgeMetrics(wallet), + }); + + const profilePath = badgeShowcasePath(wallet); + showBadgeEarnedToast({ + badgeType, + profilePath, + onViewBadges: path => onViewBadgesRef.current?.(path), + }); + }, + [getTracker, queryClient, wallet] + ); + + // Polled detection: a badge that appears after the first payload is new. + useEffect(() => { + if (!wallet || !badges) return; + + const tracker = getTracker(wallet); + + if (!tracker.seeded) { + // First payload for this wallet: everything already earned counts + // as known, so only genuinely new badges are celebrated. + tracker.seeded = true; + for (const badge of badges) { + tracker.known.add(badge.type); + } + return; + } + + for (const badge of badges) { + if (!tracker.known.has(badge.type)) { + announce(badge.type); + } + } + }, [wallet, badges, announce, getTracker]); + + // Realtime detection: the backend pushes a BadgeMinted event. + useEffect(() => { + if (!wallet) return; + + return badgeEventSocket.subscribe( + wallet, + (event: BadgeMintedEvent) => { + if (event.wallet.toLowerCase() !== wallet.toLowerCase()) return; + announce(event.badgeType, event.earnedAt); + } + ); + }, [wallet, announce]); + + return { announced }; +} + +export default useBadgeAchievements; diff --git a/src/hooks/useKeyDepth.ts b/src/hooks/useKeyDepth.ts new file mode 100644 index 00000000..688495b9 --- /dev/null +++ b/src/hooks/useKeyDepth.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + fetchKeyDepth, + KEY_DEPTH_LEVELS, +} from '@/services/keyDepth.service'; + +/** + * How often the order-book depth is refreshed (#1220). + * + * Five seconds keeps the ladder close to live without hammering the backend. + */ +export const KEY_DEPTH_REFRESH_INTERVAL_MS = 5_000; + +/** + * Order-book depth for a key. + * + * The snapshot is polled every {@link KEY_DEPTH_REFRESH_INTERVAL_MS}; React + * Query keeps the previous data in place while a refresh is in flight, so the + * chart updates in place instead of flashing a skeleton. + */ +export function useKeyDepth(keyId: string, levels: number = KEY_DEPTH_LEVELS) { + return useQuery({ + queryKey: queryKeys.creators.depth(keyId, levels), + queryFn: () => fetchKeyDepth(keyId, levels), + enabled: Boolean(keyId), + staleTime: KEY_DEPTH_REFRESH_INTERVAL_MS, + refetchInterval: KEY_DEPTH_REFRESH_INTERVAL_MS, + retry: false, + }); +} + +export default useKeyDepth; diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 98adb6b7..c3ec93f7 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -31,11 +31,16 @@ export function useWalletHoldings(address: string) { }); } +// Badges are polled so a newly earned badge reaches the showcase — and the +// achievement listener — even when the realtime feed is unavailable (#1222). +const BADGE_POLL_INTERVAL_MS = 30_000; + export function useWalletBadges(address: string) { return useQuery({ queryKey: queryKeys.wallet.badges(address), queryFn: () => fetchWalletBadges(address), enabled: !!address, + refetchInterval: BADGE_POLL_INTERVAL_MS, }); } diff --git a/src/index.css b/src/index.css index 017593ca..2088b21e 100644 --- a/src/index.css +++ b/src/index.css @@ -428,6 +428,38 @@ } } +/* + * Badge achievement reveal animation (#1222). + * + * The achievement toast plays this on the badge icon so the mint reads as a + * celebration rather than a static notification. With reduced motion the + * animation is suppressed and the icon is simply shown. + */ +@keyframes badge-reveal { + 0% { + opacity: 0; + transform: scale(0.4) rotate(-12deg); + } + 60% { + opacity: 1; + transform: scale(1.15) rotate(6deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +.badge-reveal { + animation: badge-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@media (prefers-reduced-motion: reduce) { + .badge-reveal { + animation: none; + } +} + /* * High-contrast / forced-colors overlay text guardrails (#313). * diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 0a23d78c..612a30eb 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -91,6 +91,9 @@ export const queryKeys = { ['creators', creatorId, 'early-incentive'] as const, secondaryPrices: (creatorId: string, range: string) => ['creators', creatorId, 'secondary-prices', range] as const, + // Order-book depth for the market depth chart (#1220). + depth: (creatorId: string, levels: number) => + ['creators', creatorId, 'depth', levels] as const, paymentSchedule: (creatorId: string) => ['creators', creatorId, 'payment-schedule'] as const, publicProfile: (address: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 7d708a4a..95f71eb6 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -66,6 +66,7 @@ import ShareTwitterButton from '@/components/common/ShareTwitterButton'; import { PriceHistoryChart } from '@/components/common/PriceHistoryChart'; import { BondingCurvePriceChart } from '@/components/common/BondingCurvePriceChart'; import SecondaryMarketPriceChart from '@/components/common/SecondaryMarketPriceChart'; +import { KeyDepthChart } from '@/components/common/KeyDepthChart'; import EarlyInvestorBonusChip from '@/components/common/EarlyInvestorBonusChip'; import PaymentScheduleTimeline from '@/components/common/PaymentScheduleTimeline'; import { useEarlyIncentive } from '@/hooks/useEarlyIncentive'; @@ -125,6 +126,15 @@ import { formatUnlockCountdown } from '@/utils/lockupCountdown.utils'; import KeyLockupBadge from '@/components/common/KeyLockupBadge'; import { useAccount } from 'wagmi'; +/** Series shown in the key detail price panel (#1220 adds the depth tab). */ +type PriceChartTab = 'primary' | 'secondary' | 'depth'; + +const PRICE_CHART_TABS: Array<{ id: PriceChartTab; label: string }> = [ + { id: 'primary', label: 'Price history' }, + { id: 'secondary', label: 'Secondary market' }, + { id: 'depth', label: 'Depth' }, +]; + function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -146,9 +156,7 @@ function CreatorDetailPageContent() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeSubmitting, setTradeSubmitting] = useState(false); const [interval, setInterval] = useState('24h'); - const [priceChartTab, setPriceChartTab] = useState<'primary' | 'secondary'>( - 'primary' - ); + const [priceChartTab, setPriceChartTab] = useState('primary'); const [secondaryRange, setSecondaryRange] = useState('7d'); @@ -684,40 +692,29 @@ function CreatorDetailPageContent() { {keyFreeze && } - {/* Primary / secondary market price tabs (#1234) */} + {/* Primary / secondary market price tabs (#1234) and depth tab (#1220) */}
- - + {PRICE_CHART_TABS.map(tab => ( + + ))}
{priceChartTab === 'primary' ? ( @@ -729,7 +726,7 @@ function CreatorDetailPageContent() { onIntervalChange={setInterval} /> - ) : ( + ) : priceChartTab === 'secondary' ? ( + ) : ( + + + )} {/* Live bonding-curve price chart (#1115) */} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 0e089c93..57662965 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { Link, useParams, useSearchParams } from 'react-router'; +import { useMemo, useState, useEffect } from 'react'; +import { Link, useLocation, useParams, useSearchParams } from 'react-router'; import { useAccount } from 'wagmi'; import { BarChart2, @@ -99,6 +99,7 @@ function resolveCurrentPriceStroops(priceFields?: { export default function ProfilePage() { const { wallet: publicWallet } = useParams<{ wallet: string }>(); const { address: connectedAddress } = useAccount(); + const location = useLocation(); const profile = useProfileStore(state => state.profile); const [searchParams, setSearchParams] = useSearchParams(); const requestedTab = searchParams.get('tab'); @@ -122,6 +123,23 @@ export default function ProfilePage() { ? true : isOwnWallet(connectedAddress ?? null, publicWallet); + // Deep link from the badge achievement toast (#1222): scroll the badge + // showcase into view once the page is on screen. Keyed on the location so + // the CTA still re-scrolls when the profile page is already open. + const badgeShowcaseRequested = location.hash === '#wallet-badges'; + useEffect(() => { + if (!badgeShowcaseRequested) return; + const target = document.getElementById('wallet-badges'); + const maybeTarget = target as + | (HTMLElement & { + scrollIntoView?: (options?: ScrollIntoViewOptions) => void; + }) + | null; + if (maybeTarget && typeof maybeTarget.scrollIntoView === 'function') { + maybeTarget.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [badgeShowcaseRequested, location.key]); + useDocumentTitle( isOwnProfile ? 'My Portfolio — AccessLayer' : 'Portfolio — AccessLayer' ); diff --git a/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx new file mode 100644 index 00000000..67d1f6aa --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.depthTab.integration.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import CreatorDetailPage from '../CreatorDetailPage'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { fetchKeyDepth } from '@/services/keyDepth.service'; + +vi.mock('@/hooks/useCreators', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourse: vi.fn(), + getPriceHistory: vi.fn(), + getHoldersPage: vi.fn(), + }, +})); + +vi.mock('@/services/keyDepth.service', () => ({ + fetchKeyDepth: vi.fn(), + KEY_DEPTH_LEVELS: 20, +})); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +const DEPTH_SNAPSHOT = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +const creator = { + id: 'creator-1', + title: 'Creator One', + description: 'Creator One Bio', + price: 0.5, + priceStroops: 5_000_000, + instructorId: 'inst-1', + socialHandle: 'creatorone', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Tech', + level: 'BEGINNER' as const, + creatorFeeBps: 250, + protocolFeeBps: 250, + creatorShareSupply: 100, + volume24h: 10_000_000, + priceHistory: [1_000_000, 2_000_000, 3_000_000], +}; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function renderPage() { + return render( + + + + } /> + + + + ); +} + +describe('CreatorDetailPage depth tab (#1220)', () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(useCreatorDetail).mockReturnValue({ + data: creator, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(fetchKeyDepth).mockResolvedValue(DEPTH_SNAPSHOT); + }); + + it('offers a depth tab next to the price charts', async () => { + renderPage(); + + const depthTab = await screen.findByTestId('price-chart-tab-depth'); + expect(depthTab).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('price-chart-tab-primary')).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('renders the market depth chart when the depth tab is selected', async () => { + const user = userEvent.setup(); + renderPage(); + + const depthTab = await screen.findByTestId('price-chart-tab-depth'); + await user.click(depthTab); + + await waitFor(() => { + expect(screen.getByTestId('key-depth-chart')).toBeInTheDocument(); + }); + + expect(depthTab).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('key-depth-best-bid')).toHaveTextContent('1.05 XLM'); + expect(screen.getByTestId('key-depth-best-ask')).toHaveTextContent('1.15 XLM'); + expect(screen.getByTestId('key-depth-mid-price')).toHaveTextContent('1.10 XLM'); + expect(fetchKeyDepth).toHaveBeenCalledWith('creator-1', 20); + }); +}); diff --git a/src/services/__tests__/badgeEvents.service.test.ts b/src/services/__tests__/badgeEvents.service.test.ts new file mode 100644 index 00000000..e0f95e8b --- /dev/null +++ b/src/services/__tests__/badgeEvents.service.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BadgeEventSocket, + normalizeBadgeMintedEvent, +} from '../badgeEvents.service'; +import type { WebSocketLike } from '../realtime.service'; + +/** Minimal controllable stand-in for the browser WebSocket. */ +class FakeWebSocket implements WebSocketLike { + readyState = 0; + sent: string[] = []; + closed = false; + onopen: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + + constructor(public url: string) {} + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.closed = true; + this.readyState = 3; + } + + open(): void { + this.readyState = 1; + this.onopen?.(null); + } + + emit(message: unknown): void { + this.onmessage?.({ data: JSON.stringify(message) }); + } + + drop(): void { + this.readyState = 3; + this.onclose?.(null); + } +} + +describe('normalizeBadgeMintedEvent (#1222)', () => { + it('normalizes a flat payload', () => { + const event = normalizeBadgeMintedEvent({ + wallet: '0xabc', + badgeType: 'first-key', + earnedAt: '2026-10-01T10:00:00Z', + }); + + expect(event).toEqual({ + wallet: '0xabc', + badgeType: 'first-key', + earnedAt: '2026-10-01T10:00:00.000Z', + }); + }); + + it('unwraps a { type, data } event envelope', () => { + const event = normalizeBadgeMintedEvent({ + type: 'BadgeMinted', + data: { wallet: '0xabc', badge: 'whale' }, + }); + + expect(event?.wallet).toBe('0xabc'); + expect(event?.badgeType).toBe('whale'); + expect(Number.isFinite(Date.parse(event?.earnedAt ?? ''))).toBe(true); + }); + + it('accepts field aliases and snake_case badge types', () => { + const event = normalizeBadgeMintedEvent({ + walletAddress: '0xdef', + badge_type: 'top-trader', + }); + + expect(event?.wallet).toBe('0xdef'); + expect(event?.badgeType).toBe('top-trader'); + }); + + it('reads the badge type from a flat frame type', () => { + const event = normalizeBadgeMintedEvent({ + type: 'referrer', + wallet: '0xabc', + }); + + expect(event?.badgeType).toBe('referrer'); + }); + + it('rejects frames without a wallet', () => { + expect(normalizeBadgeMintedEvent(null)).toBeNull(); + expect(normalizeBadgeMintedEvent('nope')).toBeNull(); + expect(normalizeBadgeMintedEvent({ badgeType: 'whale' })).toBeNull(); + }); + + it('rejects badge types the client cannot render', () => { + expect( + normalizeBadgeMintedEvent({ wallet: '0xabc', badgeType: 'unknown-badge' }) + ).toBeNull(); + expect( + normalizeBadgeMintedEvent({ type: 'price_update', keyId: 'key-1' }) + ).toBeNull(); + }); +}); + +describe('BadgeEventSocket (#1222)', () => { + let sockets: FakeWebSocket[]; + + const createSocket = () => + new BadgeEventSocket('wss://test.example/events', url => { + const socket = new FakeWebSocket(url); + sockets.push(socket); + return socket; + }); + + beforeEach(() => { + sockets = []; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllTimers(); + }); + + it('connects on first subscribe and sends a wallet subscribe frame', () => { + const socket = createSocket(); + socket.subscribe('0xabc', vi.fn()); + + expect(sockets).toHaveLength(1); + sockets[0].open(); + + expect(sockets[0].sent).toContainEqual( + JSON.stringify({ op: 'subscribe', topic: 'wallet:0xabc' }) + ); + expect(socket.currentStatus).toBe('connected'); + }); + + it('dispatches badge events only to handlers of the matching wallet', () => { + const socket = createSocket(); + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + socket.subscribe('0xabc', firstHandler); + socket.subscribe('0xdef', secondHandler); + sockets[0].open(); + + sockets[0].emit({ + type: 'BadgeMinted', + data: { wallet: '0xabc', badgeType: 'whale' }, + }); + + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(firstHandler).toHaveBeenCalledWith( + expect.objectContaining({ wallet: '0xabc', badgeType: 'whale' }) + ); + expect(secondHandler).not.toHaveBeenCalled(); + }); + + it('ignores malformed frames instead of throwing', () => { + const socket = createSocket(); + const handler = vi.fn(); + socket.subscribe('0xabc', handler); + sockets[0].open(); + + sockets[0].onmessage?.({ data: 'not json' }); + sockets[0].emit({ nonsense: true }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('stops delivering after unsubscribe and closes with the last handler', () => { + const socket = createSocket(); + const handler = vi.fn(); + const unsubscribe = socket.subscribe('0xabc', handler); + sockets[0].open(); + + unsubscribe(); + expect(sockets[0].sent).toContainEqual( + JSON.stringify({ op: 'unsubscribe', topic: 'wallet:0xabc' }) + ); + expect(sockets[0].closed).toBe(true); + + sockets[0].emit({ wallet: '0xabc', badgeType: 'whale' }); + expect(handler).not.toHaveBeenCalled(); + }); + + it('never connects when no URL is configured', () => { + let created = 0; + const socket = new BadgeEventSocket(undefined, url => { + created += 1; + return new FakeWebSocket(url); + }); + + const unsubscribe = socket.subscribe('0xabc', vi.fn()); + expect(created).toBe(0); + expect(socket.currentStatus).toBe('disabled'); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/src/services/__tests__/keyDepth.service.test.ts b/src/services/__tests__/keyDepth.service.test.ts new file mode 100644 index 00000000..dd3b78e5 --- /dev/null +++ b/src/services/__tests__/keyDepth.service.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ApiError } from '../api.service'; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + post: vi.fn(), + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, + }, +})); + +import { keyDepthService, normalizeKeyDepth } from '../keyDepth.service'; + +describe('keyDepthService (#1220)', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('requests 20 levels from the depth endpoint', async () => { + mockGet.mockResolvedValueOnce({ + data: { success: true, data: { bids: [], asks: [] } }, + }); + + await keyDepthService.getKeyDepth('key-1'); + + expect(mockGet).toHaveBeenCalledWith('/keys/key-1/depth', { + params: { levels: 20 }, + }); + }); + + it('honours an explicit level count', async () => { + mockGet.mockResolvedValueOnce({ + data: { success: true, data: { bids: [], asks: [] } }, + }); + + await keyDepthService.getKeyDepth('key-1', 50); + + expect(mockGet).toHaveBeenCalledWith('/keys/key-1/depth', { + params: { levels: 50 }, + }); + }); + + it('sorts the ladders with the best price first', async () => { + mockGet.mockResolvedValueOnce({ + data: { + success: true, + data: { + bids: [ + { price: 0.9, volume: 5 }, + { price: 1.1, volume: 7 }, + ], + asks: [ + { price: 1.4, volume: 3 }, + { price: 1.2, volume: 6 }, + ], + }, + }, + }); + + const snapshot = await keyDepthService.getKeyDepth('key-1'); + + expect(snapshot.bids.map(level => level.price)).toEqual([1.1, 0.9]); + expect(snapshot.asks.map(level => level.price)).toEqual([1.2, 1.4]); + }); + + it('handles server errors and wraps them in ApiError', async () => { + mockGet.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500, data: { success: false, message: 'Server error' } }, + message: 'Server error', + }); + + await expect(keyDepthService.getKeyDepth('key-1')).rejects.toThrow(ApiError); + }); +}); + +describe('normalizeKeyDepth (#1220)', () => { + it('reads the ladders from a plain payload', () => { + const snapshot = normalizeKeyDepth({ + bids: [{ price: 1.05, amount: 10 }], + asks: [{ price: 1.15, amount: 4 }], + }); + + expect(snapshot.bids).toEqual([{ price: 1.05, volume: 10 }]); + expect(snapshot.asks).toEqual([{ price: 1.15, volume: 4 }]); + }); + + it('reads a price-keyed ladder', () => { + const snapshot = normalizeKeyDepth({ + bids: { '1.05': 10, '1': 20 }, + asks: {}, + }); + + expect(snapshot.bids).toEqual([ + { price: 1.05, volume: 10 }, + { price: 1, volume: 20 }, + ]); + }); + + it('converts stroop-denominated prices to XLM', () => { + const snapshot = normalizeKeyDepth({ + bids: [{ priceStroops: 10_500_000, volume: 3 }], + asks: [], + }); + + expect(snapshot.bids[0].price).toBeCloseTo(1.05, 10); + }); + + it('reads a payload nested under depth', () => { + const snapshot = normalizeKeyDepth({ + depth: { + bids: [{ price: 1.05, volume: 2 }], + asks: [{ price: 1.1, volume: 1 }], + }, + }); + + expect(snapshot.bids).toHaveLength(1); + expect(snapshot.asks).toHaveLength(1); + }); + + it('drops levels without a usable price', () => { + const snapshot = normalizeKeyDepth({ + bids: [ + { price: 0, volume: 5 }, + { price: 1.05, volume: 2 }, + ], + asks: [], + }); + + expect(snapshot.bids).toHaveLength(1); + }); + + it('keeps the server-reported mid price and spread', () => { + const snapshot = normalizeKeyDepth({ + bids: [], + asks: [], + midPrice: 1.02, + spread: 0.04, + }); + + expect(snapshot.midPrice).toBe(1.02); + expect(snapshot.spread).toBe(0.04); + }); + + it('returns empty ladders for an unexpected payload', () => { + const snapshot = normalizeKeyDepth(null); + + expect(snapshot.bids).toEqual([]); + expect(snapshot.asks).toEqual([]); + expect(snapshot.midPrice).toBeNull(); + }); +}); diff --git a/src/services/badgeEvents.service.ts b/src/services/badgeEvents.service.ts new file mode 100644 index 00000000..4459ccb5 --- /dev/null +++ b/src/services/badgeEvents.service.ts @@ -0,0 +1,354 @@ +import { env } from '@/utils/env.utils'; +import { BADGE_CATALOG, type WalletBadgeType } from '@/utils/walletBadges.utils'; +import { + RECONNECT_BASE_DELAY_MS, + RECONNECT_MAX_DELAY_MS, + type WebSocketFactory, + type WebSocketLike, +} from './realtime.service'; + +/** + * Wallet-scoped realtime badge events (#1222). + * + * The backend broadcasts `BadgeMinted` events for creator-key holders over the + * app's WebSocket. This module owns the subscription for the connected wallet + * and fans incoming events out to listeners. + * + * It is deliberately separate from the key-price socket in `realtime.service.ts`: + * the two use different topic namespaces (`wallet:
` vs `key:`) and + * different lifecycles — the badge subscription lives for as long as a wallet + * is connected, while the price socket is opened per key and torn down as soon + * as the last key is unsubscribed. + */ + +/** A badge mint event for one wallet. */ +export interface BadgeMintedEvent { + /** Wallet the badge was minted for. */ + wallet: string; + /** Badge that was earned. */ + badgeType: WalletBadgeType; + /** ISO timestamp of the mint. */ + earnedAt: string; +} + +export type BadgeMintedHandler = (event: BadgeMintedEvent) => void; + +/** Connection lifecycle exposed to the UI. */ +export type BadgeSocketStatus = + | 'disabled' + | 'connecting' + | 'connected' + | 'reconnecting'; + +type UnknownRecord = Record; + +/** Badge types the client knows how to render. */ +const KNOWN_BADGE_TYPES = new Set( + BADGE_CATALOG.map(badge => badge.id) +); + +const OPEN_STATE = 1; + +function asRecord(value: unknown): UnknownRecord { + return typeof value === 'object' && value !== null + ? (value as UnknownRecord) + : {}; +} + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); + } + } + return null; +} + +function normalizeTimestamp(value: unknown): string { + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return new Date(parsed).toISOString(); + } + + const numeric = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(numeric)) { + // Unix seconds are occasionally broadcast instead of milliseconds. + const ms = numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; + const date = new Date(ms); + if (!Number.isNaN(date.getTime())) return date.toISOString(); + } + + return new Date().toISOString(); +} + +/** + * Resolves the badge type of an event. + * + * The badge can be named explicitly (`badgeType`, `badge`, `badgeId`) or, for + * flat frames, by the frame type itself. Unknown types are ignored so a new + * server-side badge never breaks the client. + */ +function resolveBadgeType( + raw: UnknownRecord, + envelope: UnknownRecord +): WalletBadgeType | null { + const candidates = [ + raw.badgeType, + raw.badge_type, + raw.badgeId, + raw.badge_id, + raw.badge, + envelope.badgeType, + envelope.badge_type, + raw.type, + envelope.type, + ]; + + for (const candidate of candidates) { + if ( + typeof candidate === 'string' && + KNOWN_BADGE_TYPES.has(candidate) + ) { + return candidate as WalletBadgeType; + } + } + + return null; +} + +/** + * Normalizes an incoming WebSocket frame into a {@link BadgeMintedEvent}. + * + * Accepts flat payloads as well as `{ type, data }` event envelopes and the + * handful of field aliases the feed has used so far. Returns `null` for frames + * that don't describe a usable badge mint (no wallet, or a badge type the + * client cannot render). + */ +export function normalizeBadgeMintedEvent( + value: unknown +): BadgeMintedEvent | null { + const envelope = asRecord(value); + const raw = asRecord(envelope.data ?? envelope); + + const wallet = firstString( + raw.wallet, + raw.walletAddress, + raw.wallet_address, + raw.address, + raw.owner, + raw.recipient + ); + if (!wallet) return null; + + const badgeType = resolveBadgeType(raw, envelope); + if (!badgeType) return null; + + return { + wallet, + badgeType, + earnedAt: normalizeTimestamp( + raw.earnedAt ?? raw.earned_at ?? raw.mintedAt ?? raw.timestamp ?? raw.createdAt + ), + }; +} + +/** + * App-wide wallet-scoped badge socket. + * + * Mirrors the lifecycle of the key-price socket: one shared connection, topic + * subscriptions, and exponential-backoff reconnects. + */ +export class BadgeEventSocket { + private readonly url: string | null; + private readonly createWebSocket: WebSocketFactory | null; + private readonly handlers = new Map< + string, + { address: string; listeners: Set } + >(); + private socket: WebSocketLike | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private status: BadgeSocketStatus = 'disabled'; + + constructor( + url?: string | null, + createWebSocket?: WebSocketFactory | null + ) { + const resolvedUrl = url?.trim() || null; + const defaultFactory: WebSocketFactory | null = + typeof WebSocket !== 'undefined' + ? (target: string): WebSocketLike => + new WebSocket(target) as unknown as WebSocketLike + : null; + + this.url = resolvedUrl; + this.createWebSocket = createWebSocket ?? defaultFactory; + } + + /** Whether the socket is configured and can ever connect. */ + get isEnabled(): boolean { + return this.url !== null && this.createWebSocket !== null; + } + + get currentStatus(): BadgeSocketStatus { + return this.status; + } + + /** + * Subscribes `handler` to badge events for `wallet`, connecting (or + * re-subscribing server-side) as needed. Returns the unsubscribe fn. + */ + subscribe(wallet: string, handler: BadgeMintedHandler): () => void { + if (!this.isEnabled || !wallet) return () => {}; + + // Handlers are keyed by the lower-cased address so an event that + // reports a checksummed address still reaches the subscriber; the + // original address is kept for the wire protocol. + const key = wallet.toLowerCase(); + let entry = this.handlers.get(key); + const isFirstHandlerForWallet = !entry || entry.listeners.size === 0; + if (!entry) { + entry = { address: wallet, listeners: new Set() }; + this.handlers.set(key, entry); + } + entry.listeners.add(handler); + + this.openIfNeeded(); + if (isFirstHandlerForWallet && this.isOpen()) { + this.sendSubscribe(wallet); + } + + return () => { + const current = this.handlers.get(key); + if (!current) return; + current.listeners.delete(handler); + if (current.listeners.size === 0) { + this.handlers.delete(key); + this.sendUnsubscribe(current.address); + } + if (this.handlers.size === 0) { + this.teardown(); + } + }; + } + + /** Closes the socket and drops every subscriber. */ + dispose(): void { + this.handlers.clear(); + this.teardown(); + } + + private setStatus(next: BadgeSocketStatus): void { + if (this.status === next) return; + this.status = next; + } + + private isOpen(): boolean { + return this.socket != null && this.socket.readyState === OPEN_STATE; + } + + private openIfNeeded(): void { + if (!this.isEnabled || this.socket != null) return; + + this.setStatus( + this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting' + ); + const socket = (this.createWebSocket as WebSocketFactory)( + this.url as string + ); + this.socket = socket; + + socket.onopen = () => { + if (this.socket !== socket) return; + this.reconnectAttempts = 0; + this.setStatus('connected'); + // Re-subscribe every watched wallet after a reconnect. + for (const entry of this.handlers.values()) { + this.sendSubscribe(entry.address); + } + }; + + socket.onmessage = (event: { data: unknown }) => { + if (this.socket !== socket) return; + let parsed: unknown; + try { + parsed = JSON.parse(String(event.data)); + } catch { + return; // Ignore frames that are not JSON. + } + const badgeEvent = normalizeBadgeMintedEvent(parsed); + if (!badgeEvent) return; + this.handlers + .get(badgeEvent.wallet.toLowerCase()) + ?.listeners.forEach(handler => handler(badgeEvent)); + }; + + socket.onerror = () => { + // `onclose` always follows an error; reconnect logic lives there. + }; + + socket.onclose = () => { + if (this.socket !== socket) return; + this.socket = null; + if (this.handlers.size === 0) { + this.setStatus('disabled'); + return; + } + this.scheduleReconnect(); + }; + } + + private sendSubscribe(wallet: string): void { + if (!this.isOpen()) return; + this.socket?.send( + JSON.stringify({ op: 'subscribe', topic: `wallet:${wallet}` }) + ); + } + + private sendUnsubscribe(wallet: string): void { + if (!this.isOpen()) return; + this.socket?.send( + JSON.stringify({ op: 'unsubscribe', topic: `wallet:${wallet}` }) + ); + } + + private scheduleReconnect(): void { + this.setStatus('reconnecting'); + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** this.reconnectAttempts, + RECONNECT_MAX_DELAY_MS + ); + this.reconnectAttempts += 1; + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.openIfNeeded(); + }, delay); + } + + private teardown(): void { + if (this.reconnectTimer != null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.reconnectAttempts = 0; + if (this.socket) { + this.socket.onopen = null; + this.socket.onmessage = null; + this.socket.onclose = null; + this.socket.onerror = null; + try { + this.socket.close(); + } catch { + // Already-closed sockets may throw; nothing else to do. + } + this.socket = null; + } + this.setStatus('disabled'); + } +} + +/** + * App-wide singleton. Disabled (no-op) until `VITE_WEBSOCKET_URL` is set. + */ +export const badgeEventSocket = new BadgeEventSocket(env.VITE_WEBSOCKET_URL); diff --git a/src/services/keyDepth.service.ts b/src/services/keyDepth.service.ts new file mode 100644 index 00000000..9fded84f --- /dev/null +++ b/src/services/keyDepth.service.ts @@ -0,0 +1,217 @@ +import { BaseApiService, type APIResponse } from './api.service'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +/** + * One price level of the secondary-market order book. + * + * Prices are per fraction and expressed in XLM; volumes count the fractions + * resting at the level. + */ +export interface KeyDepthLevel { + /** Price per fraction, in XLM. */ + price: number; + /** Volume resting at this level, in fractions. */ + volume: number; +} + +/** + * Normalized order-book snapshot for a key (#1220). + * + * `bids` are the open buy orders (best/highest price first) and `asks` are the + * open sell listings (best/lowest price first). + */ +export interface KeyDepthSnapshot { + /** Open buy orders, best (highest) price first. */ + bids: KeyDepthLevel[]; + /** Open sell listings, best (lowest) price first. */ + asks: KeyDepthLevel[]; + /** Server-reported mid price, when the backend provides one. */ + midPrice: number | null; + /** Server-reported absolute spread, when the backend provides one. */ + spread: number | null; +} + +/** Number of price levels requested per side of the book. */ +export const KEY_DEPTH_LEVELS = 20; + +type UnknownRecord = Record; + +/** Payload keys that may hold the bid ladder, in priority order. */ +const BID_KEYS = ['bids', 'buyOrders', 'buys', 'buy', 'bid'] as const; + +/** Payload keys that may hold the ask ladder, in priority order. */ +const ASK_KEYS = [ + 'asks', + 'sellOrders', + 'sells', + 'sell', + 'ask', + 'listings', + 'offers', +] as const; + +function asRecord(value: unknown): UnknownRecord { + return typeof value === 'object' && value !== null + ? (value as UnknownRecord) + : {}; +} + +function asNumber(value: unknown, fallback = 0): number { + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function asOptionalNumber(value: unknown): number | null { + if (value == null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Reads a level price, accepting either XLM (`price`) or stroops + * (`priceStroops`), and normalises it to XLM. + */ +function readLevelPrice(raw: UnknownRecord): number { + const stroops = asOptionalNumber(raw.priceStroops); + if (stroops != null) return stroops / STROOPS_PER_XLM; + + return asNumber(raw.price ?? raw.pricePerFraction ?? raw.rate); +} + +/** Reads the resting volume of a level, in fractions. */ +function readLevelVolume(raw: UnknownRecord): number { + const volume = + raw.volume ?? + raw.amount ?? + raw.quantity ?? + raw.size ?? + raw.total ?? + raw.fractions; + + return asNumber(volume); +} + +/** + * Normalises a single order-book level. + * + * Levels without a usable (positive) price carry no information for the + * chart and are dropped; a level with no volume is kept so the snapshot still + * reflects the book the server returned. + */ +export function normalizeDepthLevel(value: unknown): KeyDepthLevel | null { + const raw = asRecord(value); + const price = readLevelPrice(raw); + + if (!Number.isFinite(price) || price <= 0) return null; + + return { price, volume: Math.max(readLevelVolume(raw), 0) }; +} + +/** Sorts levels by price: descending for bids, ascending for asks. */ +function sortLevels( + levels: KeyDepthLevel[], + direction: 'asc' | 'desc' +): KeyDepthLevel[] { + return [...levels].sort((a, b) => + direction === 'asc' ? a.price - b.price : b.price - a.price + ); +} + +/** + * Reads a ladder that may be an array of levels or a price-keyed map + * (`{ "1.05": 12 }`). + */ +function readLadder( + payload: UnknownRecord, + keys: readonly string[] +): KeyDepthLevel[] { + for (const key of keys) { + const value = payload[key]; + + if (Array.isArray(value)) { + return value + .map(normalizeDepthLevel) + .filter((level): level is KeyDepthLevel => level !== null); + } + + if (value && typeof value === 'object') { + return Object.entries(value as UnknownRecord) + .map(([price, volume]) => normalizeDepthLevel({ price, volume })) + .filter((level): level is KeyDepthLevel => level !== null); + } + } + + return []; +} + +/** + * Flattens the payload envelope so the ladders can be read regardless of + * whether the backend nests them under `depth` / `orderBook` / `book`. + */ +function resolveDepthPayload(value: unknown): UnknownRecord { + const root = asRecord(value); + const nested = asRecord(root.depth ?? root.orderBook ?? root.book); + + if (Object.keys(nested).length === 0) return root; + + return { ...root, ...nested }; +} + +/** Reads the mid price the backend reports, when it reports one. */ +function readMidPrice(payload: UnknownRecord): number | null { + return asOptionalNumber(payload.midPrice ?? payload.mid ?? payload.lastPrice); +} + +/** Reads the absolute spread the backend reports, when it reports one. */ +function readSpread(payload: UnknownRecord): number | null { + return asOptionalNumber(payload.spread ?? payload.spreadAmount); +} + +/** + * Normalises the raw depth payload into a {@link KeyDepthSnapshot}. + * + * Exported so the shape can be unit-tested without an HTTP round trip. + */ +export function normalizeKeyDepth(value: unknown): KeyDepthSnapshot { + const payload = resolveDepthPayload(value); + + return { + bids: sortLevels(readLadder(payload, BID_KEYS), 'desc'), + asks: sortLevels(readLadder(payload, ASK_KEYS), 'asc'), + midPrice: readMidPrice(payload), + spread: readSpread(payload), + }; +} + +class KeyDepthService extends BaseApiService { + /** + * Order-book depth for a key — GET /keys/:keyId/depth?levels=20 (#1220). + * + * Returns the open buy orders (bids) and sell listings (asks) with the + * volume resting at each price level, normalised for the depth chart. + */ + async getKeyDepth( + keyId: string, + levels: number = KEY_DEPTH_LEVELS + ): Promise { + try { + const response = await this.api.get>( + `/keys/${encodeURIComponent(keyId)}/depth`, + { params: { levels } } + ); + + return normalizeKeyDepth(response.data.data); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keyDepthService = new KeyDepthService(); + +export function fetchKeyDepth( + keyId: string, + levels: number = KEY_DEPTH_LEVELS +): Promise { + return keyDepthService.getKeyDepth(keyId, levels); +} diff --git a/src/utils/__tests__/badgeAchievementToast.test.tsx b/src/utils/__tests__/badgeAchievementToast.test.tsx new file mode 100644 index 00000000..81cac167 --- /dev/null +++ b/src/utils/__tests__/badgeAchievementToast.test.tsx @@ -0,0 +1,117 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { Toaster, toast } from 'react-hot-toast'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BADGE_TOAST_DURATION_MS, + showBadgeEarnedToast, +} from '../badgeAchievementToast'; + +// Keep jsdom away from a real canvas. +vi.mock('canvas-confetti', () => ({ + default: vi.fn(), +})); + +const PROFILE_PATH = '/profile/0xabc#wallet-badges'; + +describe('showBadgeEarnedToast (#1222)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + toast.remove(); + vi.useRealTimers(); + }); + + it('shows the badge name, the criteria met and the badge icon', () => { + render(); + + act(() => { + showBadgeEarnedToast({ badgeType: 'whale', profilePath: PROFILE_PATH }); + }); + + expect(screen.getByTestId('badge-earned-toast')).toBeInTheDocument(); + expect(screen.getByTestId('badge-earned-toast-name')).toHaveTextContent( + 'Whale' + ); + expect(screen.getByTestId('badge-earned-toast-criteria')).toHaveTextContent( + 'Criteria met: Trade 10,000 XLM in creator key volume' + ); + expect(screen.getByTestId('badge-earned-toast-icon')).toBeInTheDocument(); + }); + + it('persists for ten seconds before auto-dismissing', () => { + render(); + + act(() => { + showBadgeEarnedToast({ badgeType: 'whale', profilePath: PROFILE_PATH }); + }); + + expect(BADGE_TOAST_DURATION_MS).toBe(10_000); + + act(() => { + vi.advanceTimersByTime(BADGE_TOAST_DURATION_MS - 1); + }); + expect(screen.getByTestId('badge-earned-toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(screen.queryByTestId('badge-earned-toast')).not.toBeInTheDocument(); + }); + + it('dismisses manually before the timer fires', () => { + render(); + + act(() => { + showBadgeEarnedToast({ badgeType: 'whale', profilePath: PROFILE_PATH }); + }); + + fireEvent.click(screen.getByTestId('badge-earned-toast-dismiss')); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(screen.queryByTestId('badge-earned-toast')).not.toBeInTheDocument(); + }); + + it('routes the View Badges CTA to the badge showcase and dismisses the toast', () => { + const onViewBadges = vi.fn(); + render(); + + act(() => { + showBadgeEarnedToast({ + badgeType: 'whale', + profilePath: PROFILE_PATH, + onViewBadges, + }); + }); + + fireEvent.click(screen.getByTestId('badge-earned-toast-cta')); + + expect(onViewBadges).toHaveBeenCalledWith(PROFILE_PATH); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(screen.queryByTestId('badge-earned-toast')).not.toBeInTheDocument(); + }); + + it('falls back to a generic label for a badge outside the catalog', () => { + render(); + + act(() => { + showBadgeEarnedToast({ + badgeType: 'unknown-badge' as never, + profilePath: PROFILE_PATH, + }); + }); + + expect(screen.getByTestId('badge-earned-toast-name')).toHaveTextContent( + 'New badge' + ); + expect(screen.getByTestId('badge-earned-toast-criteria')).toHaveTextContent( + 'Achievement unlocked' + ); + }); +}); diff --git a/src/utils/__tests__/keyDepthChart.utils.test.ts b/src/utils/__tests__/keyDepthChart.utils.test.ts new file mode 100644 index 00000000..1132ed96 --- /dev/null +++ b/src/utils/__tests__/keyDepthChart.utils.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDepthChartModel, + formatDepthPrice, + formatDepthPriceTick, +} from '../keyDepthChart.utils'; +import type { KeyDepthSnapshot } from '@/services/keyDepth.service'; + +const SNAPSHOT: KeyDepthSnapshot = { + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 20 }, + { price: 0.95, volume: 30 }, + ], + asks: [ + { price: 1.15, volume: 5 }, + { price: 1.2, volume: 15 }, + ], + midPrice: null, + spread: null, +}; + +describe('buildDepthChartModel (#1220)', () => { + it('returns an empty model for a missing snapshot', () => { + const model = buildDepthChartModel(null); + + expect(model.points).toEqual([]); + expect(model.bestBid).toBeNull(); + expect(model.bestAsk).toBeNull(); + expect(model.midPrice).toBeNull(); + expect(model.spread).toBeNull(); + }); + + it('returns an empty model when the book has no levels', () => { + const model = buildDepthChartModel({ + bids: [], + asks: [], + midPrice: null, + spread: null, + }); + + expect(model.points).toEqual([]); + expect(model.maxCumulativeVolume).toBe(0); + }); + + it('cumulates bid volume from the best bid downwards', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const bids = model.points.filter(point => point.side === 'bid'); + expect(bids.map(point => [point.price, point.bidVolume])).toEqual([ + [1.05, 10], + [1.0, 30], + [0.95, 60], + ]); + expect(bids.every(point => point.askVolume === null)).toBe(true); + }); + + it('cumulates ask volume from the best ask upwards', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const asks = model.points.filter(point => point.side === 'ask'); + expect(asks.map(point => [point.price, point.askVolume])).toEqual([ + [1.15, 5], + [1.2, 20], + ]); + expect(asks.every(point => point.bidVolume === null)).toBe(true); + }); + + it('keeps the level volume next to the cumulative volume', () => { + const model = buildDepthChartModel(SNAPSHOT); + + const bestBid = model.points.find(point => point.price === 1.05); + expect(bestBid?.bidAmount).toBe(10); + expect(bestBid?.bidVolume).toBe(10); + + const bestAsk = model.points.find(point => point.price === 1.15); + expect(bestAsk?.askAmount).toBe(5); + expect(bestAsk?.askVolume).toBe(5); + }); + + it('orders the plotted levels by ascending price', () => { + const model = buildDepthChartModel(SNAPSHOT); + const prices = model.points.map(point => point.price); + + expect(prices).toEqual([...prices].sort((a, b) => a - b)); + expect(prices).toEqual([0.95, 1.0, 1.05, 1.15, 1.2]); + }); + + it('resolves the best bid, best ask and mid price', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.bestBid).toBe(1.05); + expect(model.bestAsk).toBe(1.15); + expect(model.midPrice).toBe(1.1); + }); + + it('derives the spread from the best bid and ask', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.spread).toBeCloseTo(0.1, 10); + expect(model.spreadPercent).toBeCloseTo((0.1 / 1.1) * 100, 10); + }); + + it('prefers the derived spread over a server-provided one', () => { + const model = buildDepthChartModel({ + ...SNAPSHOT, + midPrice: 99, + spread: 42, + }); + + expect(model.midPrice).toBe(1.1); + expect(model.spread).toBeCloseTo(0.1, 10); + }); + + it('falls back to the server mid price when one side of the book is empty', () => { + const model = buildDepthChartModel({ + bids: SNAPSHOT.bids, + asks: [], + midPrice: 1.07, + spread: null, + }); + + expect(model.bestAsk).toBeNull(); + expect(model.midPrice).toBe(1.07); + expect(model.spread).toBeNull(); + }); + + it('totals the volume resting on each side', () => { + const model = buildDepthChartModel(SNAPSHOT); + + expect(model.totalBidVolume).toBe(60); + expect(model.totalAskVolume).toBe(20); + expect(model.maxCumulativeVolume).toBe(60); + }); + + it('centres the domain on the mid price so the marker sits mid-chart', () => { + const model = buildDepthChartModel(SNAPSHOT); + const [min, max] = model.domain; + + expect((min + max) / 2).toBeCloseTo(model.midPrice ?? 0, 10); + // The wider ladder gets the extra room. + expect(min).toBeCloseTo(0.95, 10); + expect(max).toBeCloseTo(1.25, 10); + }); + + it('keeps a usable domain for a single price level', () => { + const model = buildDepthChartModel({ + bids: [{ price: 2, volume: 1 }], + asks: [], + midPrice: null, + spread: null, + }); + + const [min, max] = model.domain; + expect(max).toBeGreaterThan(min); + expect((min + max) / 2).toBeCloseTo(2, 10); + }); + + it('ignores levels without volume or a usable price', () => { + const model = buildDepthChartModel({ + bids: [ + { price: 1.05, volume: 10 }, + { price: 1.0, volume: 0 }, + { price: 0, volume: 5 }, + ], + asks: [], + midPrice: null, + spread: null, + }); + + expect(model.points).toHaveLength(1); + expect(model.points[0].price).toBe(1.05); + }); +}); + +describe('formatDepthPrice (#1220)', () => { + it('formats prices with two decimals from one XLM up', () => { + expect(formatDepthPrice(1.5)).toBe('1.50 XLM'); + expect(formatDepthPrice(12)).toBe('12.00 XLM'); + }); + + it('keeps four decimals for sub-XLM prices', () => { + expect(formatDepthPrice(0.1234)).toBe('0.1234 XLM'); + }); + + it('falls back to a placeholder for missing prices', () => { + expect(formatDepthPrice(null)).toBe('—'); + expect(formatDepthPrice(undefined)).toBe('—'); + expect(formatDepthPrice(Number.NaN)).toBe('—'); + }); +}); + +describe('formatDepthPriceTick (#1220)', () => { + it('omits the currency suffix', () => { + expect(formatDepthPriceTick(1.5)).toBe('1.50'); + expect(formatDepthPriceTick(0.1234)).toBe('0.1234'); + }); + + it('returns an empty string for missing prices', () => { + expect(formatDepthPriceTick(null)).toBe(''); + }); +}); diff --git a/src/utils/badgeAchievementToast.tsx b/src/utils/badgeAchievementToast.tsx new file mode 100644 index 00000000..95cb8d75 --- /dev/null +++ b/src/utils/badgeAchievementToast.tsx @@ -0,0 +1,113 @@ +import toast from 'react-hot-toast'; +import { Award, Sparkles, X } from 'lucide-react'; +import { BADGE_CATALOG, type WalletBadgeType } from '@/utils/walletBadges.utils'; +import { playAchievementConfetti } from '@/utils/confetti.utils'; + +/** + * How long the badge achievement toast stays on screen (#1222). + * + * Long enough to read the badge name and tap through to the showcase. + */ +export const BADGE_TOAST_DURATION_MS = 10_000; + +export interface BadgeEarnedToastData { + /** Badge that was earned. */ + badgeType: WalletBadgeType; + /** Path to the wallet's badge showcase, used by the "View Badges" CTA. */ + profilePath: string; + /** Invoked when the user taps "View Badges". */ + onViewBadges?: (profilePath: string) => void; +} + +/** Resolves the catalog entry for a badge, if the client knows it. */ +function findBadgeDefinition( + badgeType: WalletBadgeType +): (typeof BADGE_CATALOG)[number] | undefined { + return BADGE_CATALOG.find(badge => badge.id === badgeType); +} + +/** + * #1222 — Celebrates a newly earned NFT badge. + * + * Shows the badge icon with a reveal animation, the badge name and the + * criteria that were met, plus a "View Badges" CTA that deep-links to the + * wallet's badge showcase. The toast persists for ten seconds and can be + * dismissed manually. + */ +export function showBadgeEarnedToast(data: BadgeEarnedToastData): void { + const definition = findBadgeDefinition(data.badgeType); + const Icon = definition?.icon ?? Award; + + playAchievementConfetti(); + + toast.custom( + t => ( +
+
+
+
+
+ +
+

+

+

+ {definition?.name ?? 'New badge'} +

+

+ {definition + ? `Criteria met: ${definition.criteria}` + : 'Achievement unlocked'} +

+
+ + +
+ + +
+
+ ), + { duration: BADGE_TOAST_DURATION_MS } + ); +} diff --git a/src/utils/confetti.utils.ts b/src/utils/confetti.utils.ts index 044f2d1b..bd4c2315 100644 --- a/src/utils/confetti.utils.ts +++ b/src/utils/confetti.utils.ts @@ -45,6 +45,22 @@ export function playPurchaseConfetti(durationMs = 2500): void { }, durationMs + 100); } +/** + * Fire a single celebratory confetti burst from the centre of the screen. + * + * Used for badge achievements (#1222), where a short burst reads better than + * the sustained purchase animation. + */ +export function playAchievementConfetti(): void { + confetti({ + particleCount: 90, + spread: 70, + startVelocity: 32, + origin: { y: 0.6 }, + colors: ['#34d399', '#fbbf24', '#f87171', '#e2e8f0'], + }); +} + /** * Check whether the current user has already had a successful key purchase * recorded in localStorage. diff --git a/src/utils/keyDepthChart.utils.ts b/src/utils/keyDepthChart.utils.ts new file mode 100644 index 00000000..3ccf8b95 --- /dev/null +++ b/src/utils/keyDepthChart.utils.ts @@ -0,0 +1,224 @@ +import type { + KeyDepthLevel, + KeyDepthSnapshot, +} from '@/services/keyDepth.service'; + +/** + * A single plotted price level of the depth chart. + * + * Bid levels carry a cumulative bid volume and a null ask volume; ask levels + * are the mirror image. The nulls are what create the gap between the two + * ladders around the mid price. + */ +export interface DepthChartPoint { + /** Price level on the x-axis, in XLM per fraction. */ + price: number; + /** Cumulative bid volume at (and above) this level; null on the ask side. */ + bidVolume: number | null; + /** Cumulative ask volume at (and above) this level; null on the bid side. */ + askVolume: number | null; + /** Volume resting exactly at this level (bid side). */ + bidAmount: number; + /** Volume resting exactly at this level (ask side). */ + askAmount: number; + /** Which side of the book the level belongs to. */ + side: 'bid' | 'ask'; +} + +/** Everything the depth chart needs in order to render. */ +export interface DepthChartModel { + /** Plotted levels, ascending by price. */ + points: DepthChartPoint[]; + /** Highest bid price, in XLM. */ + bestBid: number | null; + /** Lowest ask price, in XLM. */ + bestAsk: number | null; + /** Mid price between the best bid and the best ask, in XLM. */ + midPrice: number | null; + /** Absolute spread between the best ask and the best bid, in XLM. */ + spread: number | null; + /** Spread as a percentage of the mid price. */ + spreadPercent: number | null; + /** Total volume resting on the bid side. */ + totalBidVolume: number; + /** Total volume resting on the ask side. */ + totalAskVolume: number; + /** Largest cumulative volume on either side; drives the y-axis scale. */ + maxCumulativeVolume: number; + /** Price bounds of the plotted domain, symmetric around the mid price. */ + domain: [number, number]; +} + +const EMPTY_MODEL: DepthChartModel = { + points: [], + bestBid: null, + bestAsk: null, + midPrice: null, + spread: null, + spreadPercent: null, + totalBidVolume: 0, + totalAskVolume: 0, + maxCumulativeVolume: 0, + domain: [0, 0], +}; + +/** Keeps the levels that can be plotted: a positive price and resting volume. */ +function plottableLevels(levels: KeyDepthLevel[]): KeyDepthLevel[] { + return levels.filter( + level => + Number.isFinite(level.price) && + level.price > 0 && + Number.isFinite(level.volume) && + level.volume > 0 + ); +} + +/** + * Builds the cumulative ladders. + * + * Bids are walked from the best (highest) price downwards and asks from the + * best (lowest) price upwards, so the running total at a level is the volume + * that can be filled by walking the book towards that level. + */ +function buildLadder( + levels: KeyDepthLevel[], + side: 'bid' | 'ask' +): DepthChartPoint[] { + let cumulative = 0; + + return levels.map(level => { + cumulative += level.volume; + + return { + price: level.price, + bidVolume: side === 'bid' ? cumulative : null, + askVolume: side === 'ask' ? cumulative : null, + bidAmount: side === 'bid' ? level.volume : 0, + askAmount: side === 'ask' ? level.volume : 0, + side, + }; + }); +} + +/** + * Turns a depth snapshot into the series rendered by the depth chart. + * + * The two ladders are merged into a single price-ascending series so the + * chart can use one numeric x-axis: bids sit to the left of the mid price and + * asks to the right, each drawn as a cumulative step area. The plotted domain + * is symmetric around the mid price, which keeps the mid marker exactly at the + * centre of the chart. + */ +export function buildDepthChartModel( + snapshot: KeyDepthSnapshot | null | undefined +): DepthChartModel { + if (!snapshot) return EMPTY_MODEL; + + // Bids are walked from the best (highest) price downwards and asks from + // the best (lowest) price upwards, so the ladders are sorted here rather + // than relying on the caller to hand them over in order. + const bids = plottableLevels(snapshot.bids ?? []).sort( + (a, b) => b.price - a.price + ); + const asks = plottableLevels(snapshot.asks ?? []).sort( + (a, b) => a.price - b.price + ); + + if (bids.length === 0 && asks.length === 0) return EMPTY_MODEL; + + const bidPoints = buildLadder(bids, 'bid'); + const askPoints = buildLadder(asks, 'ask'); + + const points = [...bidPoints, ...askPoints].sort((a, b) => a.price - b.price); + + const bestBid = bids.length > 0 ? bids[0].price : null; + const bestAsk = asks.length > 0 ? asks[0].price : null; + + const derivedMid = + bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null; + const midPrice = derivedMid ?? snapshot.midPrice ?? bestBid ?? bestAsk ?? null; + + const derivedSpread = + bestBid != null && bestAsk != null ? bestAsk - bestBid : null; + const spread = derivedSpread ?? snapshot.spread ?? null; + + const spreadPercent = + spread != null && midPrice != null && midPrice !== 0 + ? (spread / midPrice) * 100 + : null; + + const lastBidPoint = bidPoints[bidPoints.length - 1]; + const lastAskPoint = askPoints[askPoints.length - 1]; + const totalBidVolume = lastBidPoint?.bidVolume ?? 0; + const totalAskVolume = lastAskPoint?.askVolume ?? 0; + + const maxCumulativeVolume = Math.max(totalBidVolume, totalAskVolume); + + const prices = points.map(point => point.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + + // Symmetric domain around the mid price: the green ladder and the red + // ladder get the same horizontal space, so the mid marker lands exactly at + // the centre of the plot. + let halfWidth = 0; + if (midPrice != null) { + halfWidth = Math.max(midPrice - minPrice, maxPrice - midPrice); + } + + if (halfWidth <= 0) { + const reference = midPrice ?? maxPrice; + halfWidth = Math.max(reference, 1) * 0.01; + } + + const domain: [number, number] = + midPrice != null + ? [midPrice - halfWidth, midPrice + halfWidth] + : [minPrice, maxPrice]; + + return { + points, + bestBid, + bestAsk, + midPrice, + spread, + spreadPercent, + totalBidVolume, + totalAskVolume, + maxCumulativeVolume, + domain, + }; +} + +/** + * Formats a per-fraction price for display. + * + * Sub-XLM prices keep four decimals so small quotes stay readable; anything + * from 1 XLM up is shown with two. + */ +export function formatDepthPrice(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return '—'; + + const magnitude = Math.abs(value); + const decimals = magnitude > 0 && magnitude < 1 ? 4 : 2; + + return `${value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} XLM`; +} + +/** + * Formats a price for an axis tick, without the currency suffix. + */ +export function formatDepthPriceTick(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return ''; + + const magnitude = Math.abs(value); + const decimals = magnitude > 0 && magnitude < 1 ? 4 : 2; + + return value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}