diff --git a/src/components/common/CreatorActivityFeed.tsx b/src/components/common/CreatorActivityFeed.tsx new file mode 100644 index 0000000..600f34b --- /dev/null +++ b/src/components/common/CreatorActivityFeed.tsx @@ -0,0 +1,107 @@ +import { ArrowDownRight, ArrowUpRight, History } from 'lucide-react'; +import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed'; +import { creatorActivityService } from '@/services/creatorActivity.service'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; + +export interface CreatorActivityFeedProps { + creatorId: string; +} + +const SKELETON_ROW_COUNT = 3; + +function ActivityFeedSkeletonRows() { + return ( +
+ {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +/** + * Creator profile activity feed (#698): shows the creator's recent public + * trade activity, with a proper empty state when there is none. + * + * State machine: + * loading -> skeleton rows (never the empty state, even if `trades` + * happens to still be `[]` from a previous query). + * settled, trades.length === 0 -> empty state with the exact copy from + * the issue spec. + * settled, trades.length > 0 -> real rows. + */ +const CreatorActivityFeed: React.FC = ({ creatorId }) => { + const { trades, isLoading } = useCreatorActivityFeed(creatorId, id => + creatorActivityService.getCreatorActivity(id) + ); + + if (isLoading) { + return ; + } + + if (trades.length === 0) { + return ( +
+
+
+

+ No activity yet — buy or sell keys to get started +

+
+ ); + } + + return ( +
+ {trades.map(trade => ( +
+
+ {trade.type === 'buy' ? ( + + ) : ( + + )} +
+
+
+ + {trade.type === 'buy' ? 'Buy' : 'Sell'} + + + + {formatCreatorHandle(trade.traderHandle)} + +
+
+ {trade.amount} keys + + {trade.price} XLM + + {formatRelativeTime(trade.timestamp)} +
+
+
+ ))} +
+ ); +}; + +export default CreatorActivityFeed; diff --git a/src/components/common/CreatorMarketplaceInfiniteList.tsx b/src/components/common/CreatorMarketplaceInfiniteList.tsx index 9de6aac..27774a7 100644 --- a/src/components/common/CreatorMarketplaceInfiniteList.tsx +++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx @@ -1,6 +1,8 @@ +import { useEffect, useMemo, useState } from 'react'; import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; import CreatorCard from '@/components/common/CreatorCard'; +import SearchBar from '@/components/common/SearchBar'; import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton'; import type { GetCoursesParams } from '@/services/course.service'; @@ -8,12 +10,20 @@ export interface CreatorMarketplaceInfiniteListProps { params?: Omit; } +const SEARCH_DEBOUNCE_MS = 300; + /** * Creator key marketplace listing with IntersectionObserver-driven infinite * scroll (#685): fetches the first page on mount, then automatically fetches * subsequent pages via useInfiniteQuery as the user scrolls the sentinel * element into view. Shows a skeleton row while the next page is loading and * stops fetching once the backend reports no more pages. + * + * A search bar (#699) filters the currently-loaded creators client-side by + * display name (case-insensitive substring match), debounced by 300ms. This + * filters within the page(s) already fetched rather than issuing a new + * server request per keystroke — the cursor-based pagination in + * useInfiniteCreatorMarketplace has no server-side name filter today. */ export default function CreatorMarketplaceInfiniteList({ params, @@ -27,6 +37,34 @@ export default function CreatorMarketplaceInfiniteList({ fetchNextPage, } = useInfiniteCreatorMarketplace(params); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchQuery), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Reset the filter whenever the loaded creator set changes identity due + // to a new cursor/page landing, per the issue's "filter resets when the + // page changes" acceptance criterion — a stale query shouldn't silently + // keep hiding newly-loaded creators from a previous page's context. Both + // the live input value and the debounced value used for filtering are + // cleared together so there's no window where they disagree. + const creatorsKey = creators.map(creator => creator.id).join(','); + const [lastCreatorsKey, setLastCreatorsKey] = useState(creatorsKey); + if (creatorsKey !== lastCreatorsKey) { + setLastCreatorsKey(creatorsKey); + if (searchQuery) setSearchQuery(''); + if (debouncedQuery) setDebouncedQuery(''); + } + + const trimmedQuery = debouncedQuery.trim().toLowerCase(); + const filteredCreators = useMemo(() => { + if (!trimmedQuery) return creators; + return creators.filter(creator => creator.title.toLowerCase().includes(trimmedQuery)); + }, [creators, trimmedQuery]); + const sentinelRef = useInfiniteScroll({ enabled: !isLoadingFirstPage && !isFetchingNextPage, hasMore: Boolean(hasMore), @@ -55,11 +93,27 @@ export default function CreatorMarketplaceInfiniteList({ Refreshing…
)} -
- {creators.map(creator => ( - - ))} -
+ + + {trimmedQuery && filteredCreators.length === 0 ? ( +

+ No results for "{debouncedQuery.trim()}" +

+ ) : ( +
+ {filteredCreators.map(creator => ( + + ))} +
+ )} {isFetchingNextPage && (
diff --git a/src/components/common/KeyHolderList.tsx b/src/components/common/KeyHolderList.tsx new file mode 100644 index 0000000..0c30f10 --- /dev/null +++ b/src/components/common/KeyHolderList.tsx @@ -0,0 +1,60 @@ +import { formatHolderCount, formatPercent } from '@/utils/numberFormat.utils'; +import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils'; + +export interface KeyHolderListProps { + holders: KeyHolder[]; +} + +/** + * Ranks investors by key count and shows each holder's share of the total + * supply held across the list (#697). Sorting and rank/share math live in + * `rankKeyHolders` (utils/keyHolderRanking.utils.ts) so they're covered by + * pure-function unit tests independent of rendering. + */ +const KeyHolderList: React.FC = ({ holders }) => { + const ranked = rankKeyHolders(holders); + + if (ranked.length === 0) { + return ( +

+ No holders yet. +

+ ); + } + + return ( +
    + {ranked.map(holder => ( +
  1. +
    + + {holder.rank} + + {holder.displayName} +
    +
    + + {formatHolderCount(holder.keyCount)} keys + + + {formatPercent(holder.sharePercent, { maximumFractionDigits: 1 })} + +
    +
  2. + ))} +
+ ); +}; + +export default KeyHolderList; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index b695f98..e423bd3 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -49,9 +49,18 @@ const TradeDialog: React.FC = ({ const [touched, setTouched] = useState(false); const amountInputRef = useRef(null); const pricePreviewFailureLogged = useRef(false); + // TradeDialog is opened via `open`/`onOpenChange` props from several + // different external trigger buttons (see LandingPage.tsx), never via + // Radix's own . That means Radix's built-in + // focus-return-to-trigger (which targets its own internal triggerRef) + // is always a no-op here — there is no triggerRef to return to. We + // capture whatever had focus right before the dialog opened ourselves + // and restore it in onCloseAutoFocus instead. + const triggerElementRef = useRef(null); useEffect(() => { if (open) { + triggerElementRef.current = document.activeElement as HTMLElement | null; setAmountText('1'); setTouched(false); pricePreviewFailureLogged.current = false; @@ -147,6 +156,10 @@ const TradeDialog: React.FC = ({ event.preventDefault(); amountInputRef.current?.focus(); }} + onCloseAutoFocus={event => { + event.preventDefault(); + triggerElementRef.current?.focus(); + }} onEscapeKeyDown={event => { if (isSubmitting) event.preventDefault(); }} diff --git a/src/components/common/__tests__/CreatorActivityFeed.test.tsx b/src/components/common/__tests__/CreatorActivityFeed.test.tsx new file mode 100644 index 0000000..ababee8 --- /dev/null +++ b/src/components/common/__tests__/CreatorActivityFeed.test.tsx @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; +import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed'; +import type { CreatorActivityTrade } from '@/services/creatorActivity.service'; + +vi.mock('@/hooks/useCreatorActivityFeed'); +vi.mock('@/services/creatorActivity.service', () => ({ + creatorActivityService: { getCreatorActivity: vi.fn() }, +})); + +const mockUseCreatorActivityFeed = vi.mocked(useCreatorActivityFeed); + +function makeTrade(id: string, overrides: Partial = {}): CreatorActivityTrade { + return { + id, + type: 'buy', + traderHandle: `trader-${id}`, + amount: 2, + price: 0.05, + timestamp: Date.now(), + txHash: `0x${id}`, + status: 'completed', + ...overrides, + }; +} + +describe('CreatorActivityFeed', () => { + it('shows skeleton rows while the query is loading', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [], + isLoading: true, + isError: false, + }); + + render(); + + expect(screen.getByTestId('creator-activity-feed-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument(); + }); + + it('does not show the empty state while loading, even though trades is an empty array', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [], + isLoading: true, + isError: false, + }); + + render(); + + expect(screen.queryByText('No activity yet — buy or sell keys to get started')).not.toBeInTheDocument(); + }); + + it('shows the empty state once the query has settled with no transactions', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [], + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.getByTestId('creator-activity-feed-empty-state')).toBeInTheDocument(); + expect( + screen.getByText('No activity yet — buy or sell keys to get started') + ).toBeInTheDocument(); + expect(screen.queryByTestId('creator-activity-feed-skeleton')).not.toBeInTheDocument(); + }); + + it('renders real activity rows in place of the empty state as soon as data arrives', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [makeTrade('a'), makeTrade('b', { type: 'sell' })], + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument(); + expect(screen.getByTestId('creator-activity-item-a')).toBeInTheDocument(); + expect(screen.getByTestId('creator-activity-item-b')).toBeInTheDocument(); + }); + + it('renders the exact empty-state message copy from the design spec', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [], + isLoading: false, + isError: false, + }); + + render(); + + expect( + screen.getByText('No activity yet — buy or sell keys to get started') + ).toBeInTheDocument(); + }); + + it('distinguishes buy and sell trade types in the rendered rows', () => { + mockUseCreatorActivityFeed.mockReturnValue({ + trades: [makeTrade('a', { type: 'buy' }), makeTrade('b', { type: 'sell' })], + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.getByTestId('creator-activity-item-a')).toHaveTextContent('Buy'); + expect(screen.getByTestId('creator-activity-item-b')).toHaveTextContent('Sell'); + }); +}); diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx new file mode 100644 index 0000000..a4a80fc --- /dev/null +++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx @@ -0,0 +1,168 @@ +/** + * Unit tests for the marketplace search bar (#699): filters the currently + * loaded creators client-side by display name, debounced by 300ms. + */ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import CreatorMarketplaceInfiniteList from '@/components/common/CreatorMarketplaceInfiniteList'; +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import type { Course } from '@/services/course.service'; + +vi.mock('@/hooks/useInfiniteCreatorMarketplace'); +vi.mock('@/hooks/useInfiniteScroll'); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { id: string; title: string } }) => + React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title), + }; +}); + +const mockUseInfiniteCreatorMarketplace = vi.mocked(useInfiniteCreatorMarketplace); +const mockUseInfiniteScroll = vi.mocked(useInfiniteScroll); + +function makeCreator(id: string, title: string): Course { + return { + id, + title, + description: 'desc', + price: 0.1, + instructorId: id, + category: 'Art', + level: 'BEGINNER', + }; +} + +const baseHookReturn = { + creators: [] as Course[], + hasMore: false, + isLoadingFirstPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + error: null, +}; + +describe('CreatorMarketplaceInfiniteList search', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockUseInfiniteScroll.mockReturnValue({ current: null }); + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [ + makeCreator('a', 'Alice Wonderland'), + makeCreator('b', 'Bob Builder'), + makeCreator('c', 'ALICIA Keys'), + ], + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the search input above the key list', () => { + render(); + expect(screen.getByPlaceholderText('Search creators')).toBeInTheDocument(); + }); + + it('filters the list to matching creators within 300ms of the user stopping typing', () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Search creators'), { + target: { value: 'bob' }, + }); + + // Before the debounce window elapses, the full list is still shown. + expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument(); + }); + + it('matches case-insensitively', () => { + render(); + + // "alic" is a substring of both "Alice" and "ALICIA" regardless of case. + fireEvent.change(screen.getByPlaceholderText('Search creators'), { + target: { value: 'alic' }, + }); + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument(); + expect(screen.queryByLabelText('Creator Bob Builder')).not.toBeInTheDocument(); + }); + + it('shows a "No results" empty state when no creators match the query', () => { + render(); + + fireEvent.change(screen.getByPlaceholderText('Search creators'), { + target: { value: 'zzz-no-match' }, + }); + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(screen.getByTestId('creator-marketplace-search-empty-state')).toHaveTextContent( + 'No results for "zzz-no-match"' + ); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + }); + + it('restores the full list when the input is cleared', () => { + render(); + + const input = screen.getByPlaceholderText('Search creators'); + fireEvent.change(input, { target: { value: 'bob' } }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: '' } }); + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument(); + }); + + it('resets the filter when the loaded creator set changes (new cursor/page)', () => { + const { rerender } = render(); + + fireEvent.change(screen.getByPlaceholderText('Search creators'), { + target: { value: 'bob' }, + }); + act(() => { + vi.advanceTimersByTime(300); + }); + expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument(); + + // Simulate a new page landing, changing the identity of the creators array. + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [ + makeCreator('a', 'Alice Wonderland'), + makeCreator('b', 'Bob Builder'), + makeCreator('c', 'ALICIA Keys'), + makeCreator('d', 'Dave Newcomer'), + ], + }); + rerender(); + + expect(screen.getByPlaceholderText('Search creators')).toHaveValue(''); + expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator Dave Newcomer')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/KeyHolderList.test.tsx b/src/components/common/__tests__/KeyHolderList.test.tsx new file mode 100644 index 0000000..a17f837 --- /dev/null +++ b/src/components/common/__tests__/KeyHolderList.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import KeyHolderList from '@/components/common/KeyHolderList'; +import type { KeyHolder } from '@/utils/keyHolderRanking.utils'; + +function holder(id: string, displayName: string, keyCount: number): KeyHolder { + return { id, displayName, keyCount }; +} + +describe('KeyHolderList', () => { + it('renders holders sorted descending by key count', () => { + render( + + ); + + const rows = screen.getAllByTestId('key-holder-row'); + expect(rows.map(row => within(row).getByText(/^(Alice|Bob|Cara)$/).textContent)).toEqual([ + 'Bob', + 'Cara', + 'Alice', + ]); + }); + + it('renders sequential rank numbers 1, 2, 3', () => { + render( + + ); + + const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent); + expect(ranks).toEqual(['1', '2', '3']); + }); + + it('shows a single holder with all keys at 100% share', () => { + render(); + + expect(screen.getByTestId('key-holder-share')).toHaveTextContent('100%'); + }); + + it('renders tied holders at the same rank', () => { + render( + + ); + + const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent); + expect(ranks).toEqual(['1', '1', '3']); + }); + + it('renders share percentages for each holder that are internally consistent with key counts', () => { + render( + + ); + + const shares = screen.getAllByTestId('key-holder-share').map(el => el.textContent); + expect(shares).toEqual(['70%', '30%']); + }); + + it('shows an empty state when there are no holders', () => { + render(); + + expect(screen.getByTestId('key-holder-list-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('key-holder-list')).not.toBeInTheDocument(); + }); + + it('sets an accessible label on each rank badge', () => { + render(); + + expect(screen.getByLabelText('Rank 1')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.a11y.test.tsx b/src/components/common/__tests__/TradeDialog.a11y.test.tsx new file mode 100644 index 0000000..d8d30ff --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.a11y.test.tsx @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import TradeDialog from '@/components/common/TradeDialog'; + +/** + * Keyboard accessibility for the buy key modal (#700). TradeDialog is built + * on Radix's Dialog primitive, which natively provides focus trapping, + * Escape-to-close, and focus-return-to-trigger-on-close. These tests verify + * that contract holds for THIS dialog's actual controls (amount input, + * Cancel, Confirm) rather than assuming the primitive is wired correctly, + * since `onOpenAutoFocus`/`onEscapeKeyDown` overrides in TradeDialog.tsx + * could silently break any of these if changed carelessly. + */ +describe('TradeDialog keyboard accessibility', () => { + /** + * Renders a real trigger button + TradeDialog together (mirroring how + * LandingPage.tsx wires them: a button's onClick opens the dialog), so + * focus-return-to-trigger can be verified against the actual element + * that had focus when the dialog opened, not a synthetic stand-in. + */ + function DialogWithTrigger( + overrides: Partial> = {} + ) { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); + } + + it('sets initial focus on the quantity input when the modal opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open buy dialog' })); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + }); + + it('traps focus inside the modal: Tab from Confirm (the last control) cycles back into the dialog', async () => { + const user = userEvent.setup(); + render(); + + // Capture the trigger element while it's still queryable — Radix + // marks background content aria-hidden once the dialog is open, so + // it can no longer be found via getByRole afterwards (correct + // behavior: background content should be invisible to assistive + // tech while a modal is open). + const trigger = screen.getByRole('button', { name: 'Open buy dialog' }); + await user.click(trigger); + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + screen.getByTestId('trade-dialog-confirm').focus(); + expect(screen.getByTestId('trade-dialog-confirm')).toHaveFocus(); + + await user.tab(); + + // Focus must land back inside the dialog (Radix's focus guard sends it + // to the first focusable element), never escape to the trigger button + // behind the modal — which would be reachable if the trap were broken. + expect(trigger).not.toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement).not.toBe(document.documentElement); + }); + + it('pressing Escape closes the modal', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.keyboard('{Escape}'); + + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('does not close on Escape while a submission is in flight', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await user.keyboard('{Escape}'); + + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('activates the confirm button via Enter when focused', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + screen.getByTestId('trade-dialog-confirm').focus(); + await user.keyboard('{Enter}'); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('activates the confirm button via Space when focused', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + screen.getByTestId('trade-dialog-confirm').focus(); + await user.keyboard(' '); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('is not activatable via keyboard while disabled by an invalid amount', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + render(); + + const amountInput = screen.getByTestId('trade-dialog-amount'); + await user.clear(amountInput); + amountInput.blur(); + + screen.getByTestId('trade-dialog-confirm').focus(); + await user.keyboard('{Enter}'); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('returns focus to the trigger button when the modal closes', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open buy dialog' }); + await user.click(trigger); + + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it('returns focus to the trigger when closed via the Cancel button', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: 'Open buy dialog' }); + await user.click(trigger); + await waitFor(() => { + expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus(); + }); + + await user.click(screen.getByTestId('trade-dialog-cancel')); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); +}); diff --git a/src/hooks/useCreatorActivityFeed.ts b/src/hooks/useCreatorActivityFeed.ts new file mode 100644 index 0000000..26025cb --- /dev/null +++ b/src/hooks/useCreatorActivityFeed.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { CreatorActivityTrade } from '@/services/creatorActivity.service'; + +export interface CreatorActivityFeedResult { + trades: CreatorActivityTrade[]; + isLoading: boolean; + isError: boolean; +} + +/** + * Fetches a creator's public activity feed via React Query. + * Query key: ['creators', creatorId, 'activity'] + * + * The queryFn is injected as a parameter (matching useCreatorHolderCount's + * convention) so tests can supply a mock without module-level vi.mock() + * patching. + */ +export function useCreatorActivityFeed( + creatorId: string, + fetchCreatorActivity: (id: string) => Promise +): CreatorActivityFeedResult { + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.creators.activity(creatorId), + queryFn: () => fetchCreatorActivity(creatorId), + enabled: !!creatorId, + }); + + return { + trades: data ?? [], + isLoading, + isError, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index a27c8a6..e075af2 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -10,6 +10,8 @@ export const queryKeys = { detail: (id: string) => ['creators', 'detail', id] as const, holders: (creatorId: string) => ['creators', creatorId, 'holders'] as const, + activity: (creatorId: string) => + ['creators', creatorId, 'activity'] as const, }, wallet: { holdings: (address: string) => ['wallet', address, 'holdings'] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index d550687..5e36c52 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -3,6 +3,7 @@ import { useCreatorDetail } from '@/hooks/useCreators'; import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; +import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton'; import { bpsToPercent } from '@/utils/numberFormat.utils'; import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; @@ -67,6 +68,12 @@ function CreatorDetailPageContent() {
+
+

+ Activity +

+ +
); diff --git a/src/services/creatorActivity.service.ts b/src/services/creatorActivity.service.ts new file mode 100644 index 0000000..7b30ffc --- /dev/null +++ b/src/services/creatorActivity.service.ts @@ -0,0 +1,34 @@ +// src/services/creatorActivity.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** + * A single trade entry on a creator's public activity feed. + * + * Mirrors the shape consumed by the existing `TransactionHistory` + * component so the feed can pass entries straight through. + */ +export interface CreatorActivityTrade { + id: string; + type: 'buy' | 'sell'; + traderHandle: string; + amount: number; + price: number; + timestamp: number; + txHash: string; + status: 'completed' | 'pending' | 'failed'; +} + +class CreatorActivityService extends BaseApiService { + async getCreatorActivity(creatorId: string): Promise { + try { + const response = await this.api.get>( + `/creators/${creatorId}/activity` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const creatorActivityService = new CreatorActivityService(); diff --git a/src/utils/__tests__/keyHolderRanking.utils.test.ts b/src/utils/__tests__/keyHolderRanking.utils.test.ts new file mode 100644 index 0000000..6384b69 --- /dev/null +++ b/src/utils/__tests__/keyHolderRanking.utils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils'; + +function holder(id: string, keyCount: number): KeyHolder { + return { id, displayName: `Holder ${id}`, keyCount }; +} + +describe('rankKeyHolders', () => { + it('sorts a list of three holders descending by key count', () => { + const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]); + expect(ranked.map(h => h.id)).toEqual(['b', 'c', 'a']); + }); + + it('assigns sequential ranks 1, 2, 3 in the sorted order', () => { + const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]); + expect(ranked.map(h => h.rank)).toEqual([1, 2, 3]); + }); + + it('computes share percentages that sum to 100% across all holders', () => { + const ranked = rankKeyHolders([holder('a', 30), holder('b', 50), holder('c', 20)]); + const total = ranked.reduce((sum, h) => sum + h.sharePercent, 0); + expect(total).toBeCloseTo(100, 10); + }); + + it('gives a single holder with all keys a 100% share', () => { + const ranked = rankKeyHolders([holder('a', 42)]); + expect(ranked).toHaveLength(1); + expect(ranked[0]!.sharePercent).toBe(100); + expect(ranked[0]!.rank).toBe(1); + }); + + it('renders a tie in key count at the same rank', () => { + const ranked = rankKeyHolders([holder('a', 10), holder('b', 10), holder('c', 5)]); + const [first, second, third] = ranked; + + expect(first!.keyCount).toBe(10); + expect(second!.keyCount).toBe(10); + expect(first!.rank).toBe(1); + expect(second!.rank).toBe(1); + // Competition ranking: the next distinct count resumes at its + // 1-indexed position (3rd), not incrementing by 1 (2nd). + expect(third!.rank).toBe(3); + }); + + it('splits share percentage evenly between exactly tied holders', () => { + const ranked = rankKeyHolders([holder('a', 10), holder('b', 10)]); + expect(ranked[0]!.sharePercent).toBe(50); + expect(ranked[1]!.sharePercent).toBe(50); + }); + + it('handles three-way ties by giving the following holder rank 4', () => { + const ranked = rankKeyHolders([ + holder('a', 10), + holder('b', 10), + holder('c', 10), + holder('d', 5), + ]); + expect(ranked.map(h => h.rank)).toEqual([1, 1, 1, 4]); + }); + + it('returns an empty array for an empty holder list', () => { + expect(rankKeyHolders([])).toEqual([]); + }); + + it('does not mutate the input array', () => { + const input = [holder('a', 5), holder('b', 20)]; + const inputCopy = [...input]; + rankKeyHolders(input); + expect(input).toEqual(inputCopy); + }); +}); diff --git a/src/utils/keyHolderRanking.utils.ts b/src/utils/keyHolderRanking.utils.ts new file mode 100644 index 0000000..ac95a84 --- /dev/null +++ b/src/utils/keyHolderRanking.utils.ts @@ -0,0 +1,40 @@ +export interface KeyHolder { + id: string; + displayName: string; + keyCount: number; +} + +export interface RankedKeyHolder extends KeyHolder { + rank: number; + sharePercent: number; +} + +/** + * Ranks holders descending by key count and computes each holder's share of + * the total supply held across the list. + * + * Ranks are competition-style ("1224"): holders tied on key count share the + * same rank, and the next distinct count resumes at its 1-indexed position + * rather than incrementing by 1 — e.g. two holders tied for 1st both get + * rank 1, and the following holder gets rank 3, not rank 2. + */ +export function rankKeyHolders(holders: KeyHolder[]): RankedKeyHolder[] { + const sorted = [...holders].sort((a, b) => b.keyCount - a.keyCount); + const totalKeys = sorted.reduce((sum, holder) => sum + holder.keyCount, 0); + + let currentRank = 0; + let previousKeyCount: number | null = null; + + return sorted.map((holder, index) => { + if (holder.keyCount !== previousKeyCount) { + currentRank = index + 1; + previousKeyCount = holder.keyCount; + } + + return { + ...holder, + rank: currentRank, + sharePercent: totalKeys > 0 ? (holder.keyCount / totalKeys) * 100 : 0, + }; + }); +}