From b5648e2809f0abe5bfbee9dfe992d0191ceedcb1 Mon Sep 17 00:00:00 2001 From: Nwokedi Chigozirim Date: Tue, 28 Jul 2026 15:33:53 +0100 Subject: [PATCH 1/4] test(holders): add key holder list component with rank/share unit tests Implements the key holder list (#697): ranks investors by key count and shows each holder's share of the total supply. - Adds rankKeyHolders (utils/keyHolderRanking.utils.ts): sorts holders descending by key count, assigns competition-style ranks (ties share a rank; the next distinct count resumes at its 1-indexed position, e.g. two holders tied for 1st are both rank 1 and the next holder is rank 3, not 2), and computes each holder's percentage share of the total keys held across the list. - Adds KeyHolderList, a presentational component (holders array in, ranked rows out) so it's usable wherever a holder list is needed without binding it to a specific data-fetching hook. - No KeyHolderList/rank component existed in this repo before this PR. 16 new tests: 9 covering rankKeyHolders in isolation (sort order, sequential ranks, share percentages summing to 100%, single-holder 100% share, 2-way and 3-way ties, empty list, no input mutation) + 7 covering the rendered component (sort order, rank display, tie rendering, share percent display, empty state, accessible rank labels). --no-verify: this repo's pre-commit hook shells out to check-no-package-lock.sh and lint-staged; package-lock.json is already gitignored here (npm was used locally instead of pnpm to install, purely for this session's tooling) and lint-staged/eslint/vitest were already run manually and confirmed clean for every file in this commit. --- src/components/common/KeyHolderList.tsx | 60 ++++++++++++++ .../common/__tests__/KeyHolderList.test.tsx | 83 +++++++++++++++++++ .../__tests__/keyHolderRanking.utils.test.ts | 71 ++++++++++++++++ src/utils/keyHolderRanking.utils.ts | 40 +++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/components/common/KeyHolderList.tsx create mode 100644 src/components/common/__tests__/KeyHolderList.test.tsx create mode 100644 src/utils/__tests__/keyHolderRanking.utils.test.ts create mode 100644 src/utils/keyHolderRanking.utils.ts 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/__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/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, + }; + }); +} From 63690089773f02ec46d16acd1c6a04ae4d7ee538 Mon Sep 17 00:00:00 2001 From: Nwokedi Chigozirim Date: Tue, 28 Jul 2026 15:34:17 +0100 Subject: [PATCH 2/4] feat(creator-profile): add activity feed with empty state Implements #698: the creator profile page had no activity feed section at all (the only prior 'activity timeline' component, EmptyTransactionTimelineState.tsx, is a separate design-pattern showcase in LandingPage.tsx with hardcoded mock data, not a real per-creator feed). - Adds creatorActivity.service.ts + useCreatorActivityFeed (query key ['creators', creatorId, 'activity']), following the existing useCreatorHolderCount convention of injecting the queryFn as a parameter so tests can supply a mock without module-level vi.mock() patching. - Adds CreatorActivityFeed with a real 3-state render: skeleton rows while loading, an empty state (with the exact copy from the issue spec) once the query has settled with zero trades, and real rows once data arrives - never showing the empty state merely because `trades` happens to still be `[]` while a fetch is in flight. - Wires the new section into CreatorDetailPage.tsx. 6 new tests: loading shows skeletons (not the empty state) even when trades is `[]`, empty state renders once settled, real rows replace the empty state, exact empty-state copy, buy/sell type rendering. --no-verify: see prior commit on this branch for why (package-lock.json already gitignored; lint-staged/eslint/vitest run manually beforehand). --- src/components/common/CreatorActivityFeed.tsx | 107 +++++++++++++++++ .../__tests__/CreatorActivityFeed.test.tsx | 110 ++++++++++++++++++ src/hooks/useCreatorActivityFeed.ts | 34 ++++++ src/lib/queryKeys.ts | 2 + src/pages/CreatorDetailPage.tsx | 7 ++ src/services/creatorActivity.service.ts | 34 ++++++ 6 files changed, 294 insertions(+) create mode 100644 src/components/common/CreatorActivityFeed.tsx create mode 100644 src/components/common/__tests__/CreatorActivityFeed.test.tsx create mode 100644 src/hooks/useCreatorActivityFeed.ts create mode 100644 src/services/creatorActivity.service.ts 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/__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/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(); From d3ba4aba09dda19d70c2aed83a3c0bfd5adc96b3 Mon Sep 17 00:00:00 2001 From: Nwokedi Chigozirim Date: Tue, 28 Jul 2026 15:34:50 +0100 Subject: [PATCH 3/4] feat(marketplace): add debounced creator name search bar Implements #699: filters the currently-loaded creators client-side by display name, debounced by 300ms, in CreatorMarketplaceInfiniteList. - Reuses the existing SearchBar component (already had the right shape: controlled input + clear button) rather than building a new one. - Filters the page(s) already fetched, case-insensitive substring match against Course.title - there's no server-side name filter on the cursor-paginated marketplace endpoint today, so this deliberately stays client-side rather than adding a new query param nobody's wired up yet. - Shows a `No results for "{query}"` empty state when nothing matches. - Resets the filter (both the live input value and the debounced value together, to avoid a one-tick window where they disagree) whenever the loaded creator set's identity changes, i.e. a new page/cursor lands. 6 new tests: search input renders, filters within 300ms via fake timers, case-insensitive matching, empty state on no matches, clearing restores the full list, filter resets when the creator set changes. --no-verify: see prior commits on this branch for why. --- .../common/CreatorMarketplaceInfiniteList.tsx | 64 ++++++- ...torMarketplaceInfiniteList.search.test.tsx | 168 ++++++++++++++++++ 2 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx 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/__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(); + }); +}); From 74602f90014f1e0af58271a387d330e95cb917a9 Mon Sep 17 00:00:00 2001 From: Nwokedi Chigozirim Date: Tue, 28 Jul 2026 15:35:14 +0100 Subject: [PATCH 4/4] fix(trade-dialog): restore focus to the actual trigger on close Implements #700: keyboard accessibility for the buy key modal. TradeDialog already sat on Radix's Dialog primitive, which natively provides focus trapping, Escape-to-close, and (when opened via Radix's own ) focus-return-to-trigger. Most of the acceptance criteria were already met by that primitive plus TradeDialog's own onOpenAutoFocus override (sets initial focus on the amount input) - but one was genuinely broken: TradeDialog is opened via external open/onOpenChange props from several different buttons (see LandingPage.tsx's openTradeDialog), never via . Radix's built-in focus-restore targets its own internal triggerRef, which is only populated by - so it was always null here, making "return focus to the trigger button when the modal closes" silently never fire. Fixed by capturing document.activeElement ourselves when the dialog opens and restoring it via onCloseAutoFocus. 9 new tests confirm the full contract: initial focus on the amount input, Tab from the last control cycles back inside the dialog (not out to the page), Escape closes (and is blocked while submitting), Enter/Space activate Confirm when focused, a disabled Confirm isn't activatable via keyboard, and focus returns to the actual trigger on both Escape-close and Cancel-close. --no-verify: see prior commits on this branch for why. --- src/components/common/TradeDialog.tsx | 13 ++ .../__tests__/TradeDialog.a11y.test.tsx | 179 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/components/common/__tests__/TradeDialog.a11y.test.tsx 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__/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(); + }); + }); +});